# Error Handling Source: https://docs.runpulse.com/advanced/error-handling Handle API errors gracefully and build resilient applications ## Overview Proper error handling is crucial for building reliable document processing applications. This guide covers all Pulse API error codes, retry strategies, and best practices for graceful error recovery. ## Error Response Format All errors follow a consistent JSON structure: ```json theme={null} { "error": { "code": "FILE_001", "message": "Invalid file type", "details": { "supported_types": ["PDF", "JPG", "PNG", "DOCX", "PPTX", "XLSX", "HTML"], "received_type": "DOC" } } } ``` ## Error Categories **AUTH\_XXX** - API key issues **REQ\_XXX** - Invalid parameters **FILE\_XXX** - File format/size issues **SCHEMA\_XXX** - Schema validation **BILLING\_XXX** - Usage limit issues **PROC\_XXX** - Processing failures **JOB\_XXX** - Async job issues **STORAGE\_XXX** - Storage issues **GENERAL\_XXX** - Server errors ## Complete Error Code Reference ### Authentication Errors (AUTH\_XXX) | Code | HTTP | Description | Solution | | ---------- | ---- | ---------------------------------------- | -------------------------------------------------------- | | `AUTH_001` | 401 | API key is required | Include `x-api-key` header in request | | `AUTH_002` | 401 | Invalid API key | Verify key in [Platform](https://platform.runpulse.com/) | | `AUTH_003` | 401 | API key expired | Generate new key in Platform | | `AUTH_004` | 403 | Insufficient permissions | Check API key permissions | | `AUTH_005` | 401 | Organization not found | Verify your organization exists | | `AUTH_006` | 403 | Access restricted to whitelisted domains | Contact support for domain whitelisting | ### Request Errors (REQ\_XXX) | Code | HTTP | Description | Solution | | --------- | ---- | -------------------------- | --------------------------------------------- | | `REQ_001` | 400 | No file or URL provided | Include either `file` or `file_url` parameter | | `REQ_002` | 400 | Invalid request body | Validate JSON syntax and structure | | `REQ_003` | 400 | Invalid parameter value | Check parameter format and allowed values | | `REQ_004` | 400 | Missing required parameter | Check API documentation for requirements | | `REQ_005` | 400 | Invalid chunk size | Use chunk size between 100-10000 | | `REQ_006` | 400 | Invalid page range | Use format like `1-5` or `1,3,5` | ### File Errors (FILE\_XXX) | Code | HTTP | Description | Solution | | ---------- | ---- | --------------------------- | ----------------------------------------------------------------- | | `FILE_001` | 400 | Invalid file type | Use supported formats: PDF, JPG/JPEG, PNG, DOCX, PPTX, XLSX, HTML | | `FILE_002` | 413 | File too large | Maximum file size is 100MB | | `FILE_003` | 400 | File corrupted | Verify file integrity, re-save if needed | | `FILE_004` | 400 | Empty file | Ensure file has content | | `FILE_005` | 400 | Failed to download from URL | Check URL accessibility and permissions | | `FILE_006` | 408 | Timeout downloading file | Use a faster hosting service or upload directly | | `FILE_007` | 400 | Invalid file URL | Provide a valid, accessible URL | ### Schema Errors (SCHEMA\_XXX) | Code | HTTP | Description | Solution | | ------------ | ---- | ------------------------- | ----------------------------------------------- | | `SCHEMA_001` | 400 | Invalid schema format | Ensure schema conforms to JSON Schema spec | | `SCHEMA_002` | 400 | Schema processing failed | Simplify schema or check document compatibility | | `SCHEMA_003` | 400 | Schema too complex | Reduce nesting depth (max 5 levels) | | `SCHEMA_004` | 400 | Unsupported schema type | Use supported data types only | | `SCHEMA_005` | 400 | Schema validation timeout | Simplify schema or reduce document size | ### Billing Errors (BILLING\_XXX) | Code | HTTP | Description | Solution | | ------------- | ---- | ---------------------------------------- | -------------------------------------- | | `BILLING_001` | 403 | Trial expired | Upgrade to a paid plan | | `BILLING_002` | 403 | Page limit exceeded | Upgrade plan or wait for monthly reset | | `BILLING_003` | 402 | Payment required | Add payment method in Console | | `BILLING_004` | 403 | Plan limit reached | Upgrade to a higher tier | | `BILLING_005` | 402 | Billing status unknown | Contact support | | `BILLING_006` | 403 | Account suspended | Resolve billing issues in Console | | `BILLING_007` | 402 | Payment failed | Update payment method | | `BILLING_008` | 402 | Payment requires authentication | Complete 3D Secure verification | | `BILLING_009` | 403 | Subscription canceled | Resubscribe to continue | | `BILLING_010` | 402 | Subscription past due | Update payment method | | `BILLING_011` | 403 | No active subscription | Subscribe to a plan | | `BILLING_012` | 402 | Free tier limit exceeded | Upgrade required to continue | | `BILLING_013` | 402 | Storage settings restricted on free tier | Upgrade to use storage features | ### Processing Errors (PROC\_XXX) | Code | HTTP | Description | Solution | | ---------- | ---- | ------------------------------- | -------------------------------------------------------- | | `PROC_001` | 500 | Document processing failed | Retry or contact support if persistent | | `PROC_002` | 408 | Processing timeout | Use `/extract` with `async: true` or process fewer pages | | `PROC_003` | 500 | Service temporarily unavailable | Retry after a few minutes | | `PROC_004` | 503 | Rate limit exceeded | Implement exponential backoff | | `PROC_005` | 500 | Partial extraction failure | Some elements couldn't be processed | ### Job Errors (JOB\_XXX) | Code | HTTP | Description | Solution | | --------- | ---- | --------------------- | ----------------------------------------- | | `JOB_001` | 404 | Job not found | Verify job ID; jobs expire after 48 hours | | `JOB_002` | 409 | Job already cancelled | Job cannot be modified | | `JOB_003` | 410 | Job expired | Resubmit the extraction request | | `JOB_004` | 409 | Job still processing | Wait and poll again | | `JOB_005` | 500 | Job failed | Check error details; retry if transient | ### Storage Errors (STORAGE\_XXX) | Code | HTTP | Description | Solution | | ------------- | ---- | --------------------------- | --------------------------------- | | `STORAGE_001` | 500 | Failed to store results | Retry the extraction | | `STORAGE_002` | 404 | Results not found | Results may have expired | | `STORAGE_003` | 500 | Storage service unavailable | Retry after a few minutes | | `STORAGE_004` | 507 | Storage limit exceeded | Delete old extractions or upgrade | | `STORAGE_005` | 404 | Extraction expired | Reprocess the document | ### General Errors (GENERAL\_XXX) | Code | HTTP | Description | Solution | | ------------- | ---- | ----------------------- | ----------------------------------------------------- | | `GENERAL_001` | 500 | Internal server error | Retry; contact support if persistent | | `GENERAL_002` | 503 | Service unavailable | Check status page; retry later | | `GENERAL_003` | 504 | Gateway timeout | Use `/extract` with `async: true` for large documents | | `GENERAL_004` | 429 | Too many requests | Implement rate limiting with backoff | | `GENERAL_005` | 501 | Feature not implemented | Feature not yet available | ## Handling Errors in Code ### Basic Error Handling ```python Python theme={null} from pulse import Pulse from pulse.core.api_error import ApiError client = Pulse(api_key="YOUR_API_KEY") try: response = client.extract( file_url="https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf" ) print(f"Success! Job ID: {response.job_id}") except ApiError as e: error_code = e.body.get("error", {}).get("code", "UNKNOWN") error_message = e.body.get("error", {}).get("message", "Unknown error") if error_code == "AUTH_002": print("Invalid API key. Check your credentials.") elif error_code == "FILE_001": print("Unsupported file type.") elif error_code == "FILE_002": print("File too large. Maximum is 100MB.") elif error_code.startswith("BILLING_"): print(f"Billing issue: {error_message}") elif error_code.startswith("PROC_"): print(f"Processing error (retry may help): {error_message}") else: print(f"API Error {error_code}: {error_message}") except Exception as e: print(f"Unexpected error: {e}") ``` ```typescript TypeScript theme={null} import { PulseClient, Pulse } from 'pulse-ts-sdk'; const client = new PulseClient({ apiKey: 'YOUR_API_KEY' }); try { const response = await client.extract({ fileUrl: "https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf" }); console.log(`Success! Job ID: ${response.job_id}`); } catch (error) { if (error instanceof Pulse.UnauthorizedError) { console.log("Invalid API key. Check your credentials."); } else if (error instanceof Pulse.BadRequestError) { const body = error.body as any; const errorCode = body?.error?.code || "UNKNOWN"; console.log(`Bad request: ${errorCode}`); } else if (error instanceof Pulse.TooManyRequestsError) { console.log("Rate limited. Retry after a delay."); } else { console.log(`Unexpected error: ${error}`); } } ``` ```bash curl theme={null} # Errors return JSON with error details curl -X POST https://api.runpulse.com/extract \ -H "x-api-key: INVALID_KEY" \ -H "Content-Type: application/json" \ -d '{"file_url": "https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf"}' # Response: # { # "error": { # "code": "AUTH_002", # "message": "Invalid API key" # } # } ``` ### Comprehensive Error Handler ```python theme={null} class PulseAPIError(Exception): """Custom exception for Pulse API errors.""" def __init__(self, code, message, details=None): self.code = code self.message = message self.details = details or {} super().__init__(f"{code}: {message}") class ErrorHandler: """Centralized error handling for Pulse API.""" # Retryable error codes RETRYABLE_CODES = { "FILE_005", # Download failed "FILE_006", # Download timeout "PROC_001", # Processing failed "PROC_002", # Processing timeout "PROC_003", # Service unavailable "PROC_004", # Rate limit "JOB_003", # Job expired "STORAGE_003", # Storage unavailable "GENERAL_001", # Server error "GENERAL_002", # Service unavailable "GENERAL_003", # Gateway timeout "GENERAL_004", # Rate limit } @staticmethod def handle_response(response): """Process API response and raise appropriate errors.""" if response.status_code == 200: return response.json() # Parse error response try: error_data = response.json().get("error", {}) code = error_data.get("code", str(response.status_code)) message = error_data.get("message", "Unknown error") details = error_data.get("details", {}) except: code = str(response.status_code) message = response.text or "Unknown error" details = {} # Determine if retryable is_retryable = code in ErrorHandler.RETRYABLE_CODES # Create appropriate exception error = PulseAPIError(code, message, details) error.is_retryable = is_retryable raise error ``` ## Retry Strategies ### Exponential Backoff ```python Python theme={null} import time import random from pulse import Pulse from pulse.core.api_error import ApiError client = Pulse(api_key="YOUR_API_KEY") RETRYABLE_CODES = {"PROC_001", "PROC_002", "PROC_003", "PROC_004", "GENERAL_001", "GENERAL_002", "GENERAL_004"} def extract_with_retry(file_url, max_retries=3, base_delay=1): """Extract with exponential backoff retry.""" for attempt in range(max_retries): try: return client.extract(file_url=file_url) except ApiError as e: error_code = e.body.get("error", {}).get("code", "") if error_code not in RETRYABLE_CODES or attempt == max_retries - 1: raise delay = base_delay * (2 ** attempt) + random.uniform(0, 1) print(f"Retry {attempt + 1}/{max_retries} after {delay:.1f}s") time.sleep(delay) raise Exception("Max retries exceeded") # Usage result = extract_with_retry("https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf") ``` ```typescript TypeScript theme={null} import { PulseClient, Pulse } from 'pulse-ts-sdk'; const client = new PulseClient({ apiKey: 'YOUR_API_KEY' }); const RETRYABLE_CODES = new Set([ "PROC_001", "PROC_002", "PROC_003", "PROC_004", "GENERAL_001", "GENERAL_002", "GENERAL_004" ]); async function extractWithRetry( fileUrl: string, maxRetries = 3, baseDelay = 1000 ): Promise { for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await client.extract({ fileUrl }); } catch (error: any) { const errorCode = error.body?.error?.code || ""; if (!RETRYABLE_CODES.has(errorCode) || attempt === maxRetries - 1) { throw error; } const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000; console.log(`Retry ${attempt + 1}/${maxRetries} after ${delay.toFixed(0)}ms`); await new Promise(resolve => setTimeout(resolve, delay)); } } throw new Error("Max retries exceeded"); } // Usage const result = await extractWithRetry("https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf"); ``` ### Circuit Breaker Pattern ```python theme={null} from datetime import datetime, timedelta class CircuitBreaker: """Prevent cascading failures with circuit breaker.""" def __init__(self, failure_threshold=5, recovery_timeout=60): self.failure_threshold = failure_threshold self.recovery_timeout = recovery_timeout self.failure_count = 0 self.last_failure_time = None self.state = "closed" # closed, open, half-open def call(self, func): """Execute function with circuit breaker protection.""" if self.state == "open": if datetime.now() - self.last_failure_time > timedelta(seconds=self.recovery_timeout): self.state = "half-open" self.failure_count = 0 else: raise Exception("Circuit breaker is open") try: result = func() if self.state == "half-open": self.state = "closed" return result except Exception as e: self.failure_count += 1 self.last_failure_time = datetime.now() if self.failure_count >= self.failure_threshold: self.state = "open" print(f"Circuit breaker opened after {self.failure_count} failures") raise # Usage breaker = CircuitBreaker() try: result = breaker.call(lambda: client.extract(file_url="document.pdf")) except Exception as e: print(f"Failed: {e}") ``` ### Intelligent Retry Logic ```python theme={null} class SmartRetry: """Intelligent retry with different strategies per error type.""" def __init__(self): self.strategies = { "FILE_005": self.retry_with_backoff, # Download failed "FILE_006": self.retry_with_backoff, # Download timeout "PROC_002": self.retry_with_smaller_chunk, # Processing timeout "PROC_004": self.handle_rate_limit, # Rate limit exceeded "JOB_003": self.retry_with_smaller_chunk, # Job expired "GENERAL_004": self.handle_rate_limit, # Too many requests "GENERAL_002": self.retry_with_backoff, # Service unavailable } def execute(self, func, context=None): """Execute with smart retry logic.""" max_attempts = 3 for attempt in range(max_attempts): try: return func() except PulseAPIError as e: if attempt == max_attempts - 1: raise strategy = self.strategies.get(e.code, self.retry_with_backoff) strategy(e, attempt, context) def retry_with_backoff(self, error, attempt, context): """Standard exponential backoff.""" delay = 2 ** attempt print(f"Retrying after {delay}s due to {error.code}") time.sleep(delay) def handle_rate_limit(self, error, attempt, context): """Handle rate limiting with longer delay.""" print("Rate limited. Waiting 60 seconds...") time.sleep(60) def retry_with_smaller_chunk(self, error, attempt, context): """Retry with smaller page range for timeouts.""" if context and 'pages' in context: # Reduce page range current_pages = context['pages'] # Logic to split page range print(f"Retrying with smaller page range") time.sleep(5) ``` ## Error Recovery Patterns ### Graceful Degradation ```python theme={null} def extract_with_fallback(file_path, preferred_mode="full"): """Extract with graceful degradation.""" strategies = [ # Try full extraction with schema lambda: client.extract( file_url=file_path, schema=complex_schema ), # Fallback to simple extraction lambda: client.extract( file_url=file_path, schema=simple_schema ), # Last resort: text only lambda: client.extract( file_url=file_path ) ] for i, strategy in enumerate(strategies): try: print(f"Attempting strategy {i + 1}/{len(strategies)}") return strategy() except PulseAPIError as e: if i == len(strategies) - 1: raise print(f"Strategy {i + 1} failed: {e.code}, trying next...") ``` ### Partial Success Handling ```python theme={null} def process_large_document_with_recovery(file_path, total_pages=100): """Process document in chunks with partial success.""" chunk_size = 10 results = [] failed_chunks = [] for start in range(0, total_pages, chunk_size): end = min(start + chunk_size - 1, total_pages - 1) page_range = f"{start + 1}-{end + 1}" try: result = client.extract( file_url=file_path, pages=page_range ) results.append({ "pages": page_range, "content": result }) except PulseAPIError as e: print(f"Failed to process pages {page_range}: {e}") failed_chunks.append(page_range) # Retry failed chunks with different strategy for chunk in failed_chunks: try: # Try with smaller chunks or different parameters result = client.extract( file_url=file_path, pages=chunk ) results.append({ "pages": chunk, "content": result, "recovered": True }) except: print(f"Permanently failed: {chunk}") return results ``` ## Best Practices * Never assume API calls will succeed * Catch and handle specific error codes * Provide meaningful error messages to users * Log errors for debugging * Use exponential backoff for transient errors * Set reasonable retry limits * Only retry retryable errors * Add jitter to prevent thundering herd * Track error frequencies * Alert on error spikes * Analyze patterns for optimization * Review logs regularly * Have fallback strategies * Accept partial success * Inform users of degraded functionality * Queue for later retry if appropriate ## Common Error Scenarios ### Scenario 1: File Upload Issues ```python theme={null} def upload_with_validation(file_path): """Upload file with pre-validation.""" # Check file extension valid_extensions = ['.pdf', '.jpg', '.jpeg', '.png', '.docx', '.pptx', '.xlsx', '.html'] file_ext = os.path.splitext(file_path)[1].lower() if file_ext not in valid_extensions: raise ValueError(f"Unsupported file type: {file_ext}") # Attempt upload with retry return exponential_backoff_retry( lambda: client.extract(file_url=file_url) ) ``` ### Scenario 2: Async Job Management ```python theme={null} def manage_async_job(job_id): """Robustly manage async job lifecycle.""" max_poll_time = 600 # 10 minutes poll_interval = 5 start_time = time.time() while time.time() - start_time < max_poll_time: try: status = client.jobs.get_job(job_id=job_id) if status['status'] == 'completed': return status['result'] elif status['status'] == 'failed': raise PulseAPIError( "JOB_004", f"Job failed: {status.get('error', 'Unknown error')}" ) elif status['status'] == 'cancelled': raise PulseAPIError("JOB_002", "Job was cancelled") time.sleep(poll_interval) except PulseAPIError as e: if e.code == "JOB_001": # Job not found - might be eventual consistency issue time.sleep(10) continue raise except requests.exceptions.RequestException: # Network error - retry time.sleep(poll_interval) continue # Timeout - attempt to cancel try: client.jobs.cancel_job(job_id=job_id) except: pass raise TimeoutError(f"Job {job_id} timed out after {max_poll_time}s") ``` ## Next Steps See endpoint details # Pay Per Request (MPP) Source: https://docs.runpulse.com/advanced/mpp-payments Let AI agents pay for extractions with stablecoins over HTTP 402 — no account or API key required > Bring Pulse to autonomous agents: no signup, no API key — pay per document with USDC on Tempo using the open [Machine Payments Protocol (MPP)](https://mpp.dev). Every Pulse endpoint normally requires an [API key](/authentication). MPP adds a second door for agents: call `POST /extract` with **no credentials**, receive an HTTP `402 Payment Required` challenge, pay the quoted amount on the [Tempo](https://tempo.xyz) blockchain, and retry to get your extraction — plus a 24-hour session key for follow-up work on the same document. MPP payments use **push mode**: your client broadcasts the stablecoin transfer itself and presents the transaction hash as proof. Clients that only send pull-mode credentials (for example `pympp` 0.9.1, or the Tempo CLI's `tempo request` as of v0.6.7) cannot pay Pulse today — use [`mppx`](https://mpp.dev/payment-methods/tempo/charge) configured for push mode, or the manual flow below. ## Before you start You need a Tempo wallet holding enough USDC to cover your extractions (a few cents per document) plus gas: * **Wallet + funds**: create a wallet and buy or bridge USDC by following the [Tempo docs](https://docs.tempo.xyz), or use the [Tempo CLI](https://tempo.xyz) (`tempo wallet login`, `tempo wallet fund`). * **RPC**: broadcast transactions through `https://rpc.tempo.xyz` (or your own node). ## How it works ```mermaid theme={null} sequenceDiagram participant A as Agent participant P as Pulse API participant T as Tempo chain A->>P: POST /extract (no credentials) P-->>A: 402 + WWW-Authenticate: Payment challenge A->>T: Transfer exact USDC amount to challenge recipient T-->>A: Transaction hash A->>P: POST /extract (identical request) + Authorization: Payment credential P-->>A: 200 extraction result + mpp_session key + Payment-Receipt ``` The challenge quotes a price for **your exact document and parameters** — the server counts the pages before charging you. The paid retry must re-send the same document (byte-identical bytes, uploaded the same way with the same filename extension, or the same `file_url` serving unchanged bytes) with the same `model` and `pages` parameters; the challenge is cryptographically bound to them. Both JSON `file_url` requests and multipart `file` uploads are supported — re-send the full request body on every retry. The server re-fetches `file_url` on the paid retry. If the bytes behind the URL change — or a pre-signed URL expires — verification fails and your payment cannot be applied. Use content-stable URLs with a lifetime comfortably beyond the 10-minute challenge window, or upload the file directly. ## Pricing MPP charges the same credits as authenticated pay-as-you-go, settled in USDC at **\$0.015 per credit**, rounded up to the next whole cent per payment. | Endpoint | Credits | | ---------- | --------------------------------------------------------- | | `/extract` | 1 per page (`pulse-ultra-2`: 10 per page) | | `/split` | 2 per page | | `/schema` | 1 per page (+3 per page with `effort: true`) | | `/tables` | 0.25 per table (+0.25 per table with merge, +1 per chart) | For example, extracting a 2-page PDF costs 2 credits = **\$0.03**, quoted in the challenge as `30000` base units of USDC (6 decimals). ## Quick start ```bash theme={null} curl -i -X POST https://api.runpulse.com/extract \ -H "Content-Type: application/json" \ -d '{"file_url": "https://www.impact-bank.com/user/file/dummy_statement.pdf"}' ``` The response is `402 Payment Required` with the challenge in the `WWW-Authenticate` header: ``` WWW-Authenticate: Payment id="33v1kk…", realm="pulse", method="tempo", intent="charge", expires="2026-07-20T01:09:49Z", request="eyJhbW91bnQiOiIzMDAwMCIs…", opaque="eyJjcmVkaXRzIjoi…" ``` `request` is base64url JSON with the `=` padding stripped (re-pad to a multiple of 4 before decoding): ```json theme={null} { "amount": "30000", "currency": "0x20c000000000000000000000b9537d11c60e8b50", "recipient": "0x8dc2668eb15aac7754a33195f5e1066f254f2db6", "description": "Pulse extraction: 2 page(s)", "methodDetails": { "chainId": 4217, "supportedModes": ["push"] } } ``` `amount` is in USDC base units (divide by 10⁶ for dollars), `currency` is the USDC token contract, and `recipient` is a **one-time deposit address minted for this challenge only**. Pay on the chain the challenge names — `chainId` `4217` is Tempo mainnet — using the `currency` and `recipient` values exactly as given. Transfer **exactly** `amount` of the `currency` token to `recipient` on the challenge's `chainId`, and keep the transaction hash. The challenge expires 10 minutes after it was minted; a payment broadcast in time stays redeemable for roughly 10 more minutes after expiry. Re-send the **identical** request with an `Authorization: Payment ` header (format below). On success you get the normal [extraction response](/api-reference/endpoint/extract) plus an `mpp_session` object and a `Payment-Receipt` header: ```json theme={null} { "markdown": "…", "extraction_id": "0ff87d8a-8234-4c4f-a3e1-5d0371b8ff7b", "page_count": 2, "credits_used": 2.0, "mpp_session": { "api_key": "YS735gm_…", "org_id": "eorg-5cb0983714345e1b", "expires_at": "2026-07-21T01:30:55Z" } } ``` `Payment-Receipt` is unsigned base64url JSON — `{"status": "success", "method": "tempo", "timestamp": …, "reference": ""}` — keep it with your records; you don't need it for later calls. Pay the **exact amount** to the **exact recipient** of the challenge you will present, within its window. Deposit matching is exact-match per challenge: funds sent to a different challenge's address, or an over- or underpaid transfer, do not fulfill your challenge and are **not automatically returned**. An underpaid deposit to the correct address is a terminal failure — never top it up or pay the same challenge twice. If you strand funds, contact [support](/support) with your transaction hash. ## The payment credential The `Authorization` header value is `Payment ` followed by base64url JSON (padding stripped): ```json theme={null} { "challenge": { "id": "…", "realm": "pulse", "method": "tempo", "intent": "charge", "expires": "…", "request": "…", "opaque": "…" }, "payload": { "type": "hash", "hash": "0x40bfb1de…" } } ``` Echo the challenge fields **verbatim as received** — keep `request` and `opaque` as their original base64url strings. `payload.hash` is your Tempo transaction hash. Only `payload.type: "hash"` is accepted; pull-mode `transaction` payloads are rejected. You may additionally include a `source` field (`did:pkh:eip155::
`) identifying the payer — it is optional and not validated. ```typescript mppx (automatic) theme={null} import { Mppx, tempo } from "mppx/client"; import { createClient, http } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { tempo as tempoChain } from "viem/chains"; const account = privateKeyToAccount(process.env.TEMPO_PRIVATE_KEY as `0x${string}`); const mppx = Mppx.create({ methods: [ tempo.charge({ account, mode: "push", // Pulse is push-only; local accounts default to pull getClient: () => createClient({ chain: tempoChain, transport: http("https://rpc.tempo.xyz") }), expectedChainId: 4217, }), ], polyfill: false, // don't patch global fetch; use mppx.fetch explicitly }); // mppx handles the 402 → pay → retry loop for you const response = await mppx.fetch("https://api.runpulse.com/extract", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ file_url: "https://www.impact-bank.com/user/file/dummy_statement.pdf", }), }); const result = await response.json(); console.log(result.markdown, result.mpp_session.api_key); ``` ```python Python (manual) theme={null} import base64, json, re, requests url = "https://api.runpulse.com/extract" body = {"file_url": "https://www.impact-bank.com/user/file/dummy_statement.pdf"} def b64url_decode(value): return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) # 1. Get the challenge r = requests.post(url, json=body) header = r.headers["WWW-Authenticate"] fields = dict(re.findall(r'(\w+)="([^"]*)"', header)) request = json.loads(b64url_decode(fields["request"])) # 2. Pay request["amount"] of request["currency"] to request["recipient"] # on Tempo with your wallet library, and capture the tx hash tx_hash = pay_on_tempo(request) # your signer here # 3. Build the credential and retry the identical request credential = { "challenge": {k: fields[k] for k in ("id", "realm", "method", "intent", "expires", "request", "opaque")}, "payload": {"type": "hash", "hash": tx_hash}, } token = base64.urlsafe_b64encode( json.dumps(credential, separators=(",", ":")).encode() ).decode().rstrip("=") r = requests.post(url, json=body, headers={"Authorization": f"Payment {token}"}) print(r.json()["markdown"]) ``` ## Paying from an agent with the Tempo wallet If your agent uses the [Tempo Wallet CLI](https://docs.tempo.xyz/docs/cli), it can complete the whole flow with wallet commands — no signing code required. Set it up once: ```bash theme={null} # Teach your agent the Tempo wallet (Claude Code shown; works the same for other agents) claude -p "Read https://tempo.xyz/SKILL.md and set up tempo" # Or expose the wallet to your agent as an MCP server tempo wallet mcp add --agent claude-code ``` Then hand the agent this recipe along with your document: ```text theme={null} Extract with Pulse (https://api.runpulse.com/extract), paying over MPP: 1. POST {"file_url": ""} with no credentials and read the 402 WWW-Authenticate: Payment challenge. 2. Decode the challenge's base64url `request` field: it gives `amount` (USDC base units), `currency` (token contract), and a one-time `recipient` address. 3. Pay with: tempo wallet transfer and keep the tx hash. Do not use `tempo request` — it cannot pay push-mode servers. 4. Retry the identical request with an Authorization: Payment header: base64url JSON echoing the challenge fields verbatim plus payload {"type": "hash", "hash": ""}. 5. Stop and ask me before paying if the quoted amount exceeds my budget. ``` Cap what an agent can spend with your wallet's access-key spending limits (`tempo wallet keys`) — the key it holds cannot move more than the limit you set, whatever the agent does. ## The session key A successful payment returns `mpp_session.api_key` — a real API key scoped to an ephemeral organization created just for your payment. Use it as `x-api-key` for **24 hours** to continue working with the document you paid for: | Capability | How | | ----------------------- | ------------------------------------------------------------------------------- | | Retrieve results again | `GET /job/{extraction_id}` | | Split the document | `POST /split` — priced per call, paid with a fresh 402 challenge | | Extract structured data | `POST /schema` — priced per call, paid with a fresh 402 challenge | | Extract tables | `POST /tables` — runs first, then quotes a 402 priced on the actual table count | Chained calls authenticate with the session key **and** pay per call: the key proves who you are, the 402 flow settles each new charge. The credential format is identical to the first payment, and paid responses carry a `Payment-Receipt` header. The session key cannot start extractions of new documents — begin a fresh anonymous 402 flow for those. Session limits: synchronous requests only, `/schema` works on one extraction at a time (no batch or split mode), and up to 5 unpaid `/tables` computations per session. New chained challenges are refused in the last \~20 minutes of the session. Results are purged when the session's retention window ends, so collect everything you need within 24 hours. ## Errors and retries Payment errors are `402` responses in RFC 9457 `application/problem+json` format (`{"title", "detail", "status", "type"}`); other validation and rate-limit errors use the standard `{"error": {"code", "message"}}` envelope. The rule that matters: **only a 402 that carries a `WWW-Authenticate` challenge is payable.** For a 402 *without* a challenge, follow the table — never pay again. | Response | Challenge attached? | What to do | | ------------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `Payment detected but not yet confirmed` (`Retry-After: 30`) | No | Retry the same request + same header after the delay, for up to \~10 minutes | | `Settlement is temporarily unavailable` (`Retry-After: 30`) | No | Retry the same request + same header | | `The submitted transaction hash does not match the settled payment` | No | Re-send with the tx hash that actually paid this challenge — do not pay again | | `Payment verification failed: …` | Yes (fresh) | Fix the cause (expired challenge, changed file or params, malformed hash) and pay the fresh challenge | | `The payment was declined or the deposit never matched` | Yes (fresh) | First verify on-chain whether your earlier transfer confirmed to the earlier recipient; only then pay the fresh challenge | | `The deposit was less than the amount due` | No | **Terminal** — do not pay this challenge again | | `This payment was already redeemed` | No | **Terminal** — start a new request for another extraction | On `/tables` paid retries, verification failures are returned **without** a fresh challenge — re-present the same credential if the cause was transient, or start a new `/tables` request. Anonymous requests are also rate-limited to 30 per hour per IP (`429 GENERAL_004`; during busy periods a shared limit can briefly return 429s as well — honor `Retry-After`). Anonymous files are capped at **50 MB** regardless of model (`FILE_002`), and `async` mode is not available for anonymous requests (`REQ_003`). ## Next steps Prefer a standing account? Get an API key instead. Full request and response reference for POST /extract. Chain split, schema, and tables on your paid extraction. Error codes and retry guidance for the whole API. # Async Processing Source: https://docs.runpulse.com/api-reference/async-processing Process documents asynchronously with polling # Asynchronous Processing For large documents or production workflows, use async processing to avoid timeouts and handle long-running operations gracefully. ## How It Works 1. **Submit** - Send your request with `async: true` 2. **Receive job ID** - Get an immediate response with a `job_id` 3. **Poll** - Check job status via `GET /job/{jobId}` 4. **Get results** - Retrieve completed results from the poll response ```mermaid theme={null} sequenceDiagram participant Client participant API participant Worker Client->>API: POST /extract (async: true) API-->>Client: 202 {job_id, status: "pending"} API->>Worker: Queue job loop Poll for results Client->>API: GET /job/{job_id} API-->>Client: {status: "processing"} end Worker->>API: Job complete Client->>API: GET /job/{job_id} API-->>Client: {status: "completed", result: {...}} ``` *** ## Endpoints with Async Support | Endpoint | Async Flag | Async Response | | ---------------- | ------------- | ----------------- | | `POST /classify` | `async: true` | 202 with `job_id` | | `POST /extract` | `async: true` | 202 with `job_id` | | `POST /schema` | `async: true` | 202 with `job_id` | | `POST /tables` | `async: true` | 202 with `job_id` | | `POST /split` | `async: true` | 202 with `job_id` | `POST /extract_async` is **deprecated**. Use `POST /extract` with `async: true` instead. *** ## Using the Async Flag Add `async: true` to any supported endpoint's request body: ```python Python theme={null} from pulse import Pulse client = Pulse(api_key="YOUR_API_KEY") # Async extraction job = client.extract( file_url="https://example.com/large-report.pdf", async_=True # Note: async_ in Python (async is reserved) ) print(f"Job ID: {job.job_id}") print(f"Status: {job.status}") # "pending" # Async schema extraction job = client.schema( extraction_id="abc123", schema_config={"input_schema": {...}, "schema_prompt": "..."}, async_=True ) # Async split job = client.split( extraction_id="abc123", split_config={"split_input": [{"name": "financials", "description": "..."}]}, async_=True ) ``` ```typescript TypeScript theme={null} import { PulseClient } from 'pulse-ts-sdk'; const client = new PulseClient({ apiKey: 'YOUR_API_KEY' }); // Async extraction const job = await client.extract({ fileUrl: "https://example.com/large-report.pdf", async: true }); console.log(`Job ID: ${job.job_id}`); // Async schema extraction const schemaJob = await client.schema({ extraction_id: "abc123", schema_config: { input_schema: {}, schema_prompt: "Extract details" }, async: true }); ``` ```bash curl theme={null} # Async extraction curl -X POST https://api.runpulse.com/extract \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "file_url": "https://example.com/report.pdf", "async": true }' # Response: {"job_id": "abc123", "status": "pending"} ``` *** ## Async Response Format When `async: true`, you receive a **202 Accepted** response: ```json theme={null} { "job_id": "abc123-def456-ghi789", "status": "pending" } ``` | Field | Type | Description | | -------- | ------ | ----------------------------------------- | | `job_id` | string | Unique identifier for the async job | | `status` | string | Initial status: `pending` or `processing` | *** ## Polling for Results Use `GET /job/{jobId}` to check status and retrieve results: ```python Python theme={null} import time job_id = job.job_id while True: status = client.jobs.get_job(job_id=job_id) print(f"Status: {status.status}") if status.status == "completed": print("Done!") print(f"Result: {status.result}") break elif status.status == "failed": print(f"Failed: {status.error}") break elif status.status == "canceled": print("Job was canceled") break time.sleep(2) # Poll every 2 seconds ``` ```typescript TypeScript theme={null} const pollForResult = async (jobId: string) => { while (true) { const status = await client.jobs.getJob({ jobId }); console.log(`Status: ${status.status}`); if (status.status === 'completed') { return status.result; } else if (status.status === 'failed') { throw new Error(status.error); } else if (status.status === 'canceled') { throw new Error('Job canceled'); } await new Promise(r => setTimeout(r, 2000)); } }; const result = await pollForResult(job.job_id); ``` ```bash curl theme={null} # Poll for status curl "https://api.runpulse.com/job/abc123-def456" \ -H "x-api-key: YOUR_API_KEY" ``` ### Poll Response ```json theme={null} { "job_id": "abc123-def456-ghi789", "status": "completed", "created_at": "2026-02-04T10:30:00Z", "completed_at": "2026-02-04T10:30:45Z", "result": { "markdown": "# Document Content...", "page_count": 50, "structured_output": {...} } } ``` ### Job Status Values | Status | Description | | ------------ | --------------------------------------------- | | `pending` | Job is queued, waiting to start | | `processing` | Job is currently running | | `completed` | Job finished successfully - results available | | `failed` | Job encountered an error | | `canceled` | Job was canceled by user | | `expired` | Job passed its retention window | *** ## Canceling Jobs Cancel a running job with `DELETE /job/{jobId}`: ```python Python theme={null} client.jobs.cancel_job(job_id=job_id) ``` ```bash curl theme={null} curl -X DELETE "https://api.runpulse.com/job/abc123" \ -H "x-api-key: YOUR_API_KEY" ``` *** ## When to Use Async Synchronous requests may timeout for large documents. Always use async for documents over 50 pages. Schema extraction with many fields or nested structures benefits from async processing. Async provides better reliability and allows you to handle failures gracefully with retries. Submit multiple documents asynchronously and poll for results in parallel. *** ## Sync vs Async Comparison | Aspect | Sync (`async: false`) | Async (`async: true`) | | -------------- | --------------------- | ---------------------- | | Response | Full result | Job ID only | | HTTP Status | 200 | 202 | | Timeout risk | Higher | Lower | | Best for | Small docs, testing | Production, large docs | | Polling needed | No | Yes | *** ## Webhooks Alternative Instead of polling, you can use [webhooks](/svix-webhooks) to receive notifications when jobs complete: ```python theme={null} # Generate a one-time link to the Svix webhook portal webhook_link = client.webhooks.create_webhook_link() print(f"Configure webhooks at: {webhook_link.link}") # Submit async job - webhook will notify on completion job = client.extract(file_url="...", async_=True) ``` See [Svix Webhooks](/svix-webhooks) for setup instructions. # Bounding Boxes Source: https://docs.runpulse.com/api-reference/bounding-boxes Understanding layout information from document extraction ## Overview When extracting content with layout information, Pulse API returns bounding box coordinates for text, tables, and images. This spatial data enables precise document understanding and region-based extraction. ## Bounding Box Format Bounding boxes are returned as normalized coordinates (0-1 range) in an 8-point format: ``` [x1, y1, x2, y2, x3, y3, x4, y4] ``` Where: * **(x1, y1)** = Top-left corner * **(x2, y2)** = Top-right corner * **(x3, y3)** = Bottom-right corner * **(x4, y4)** = Bottom-left corner Coordinates are normalized to 0-1 range, making them resolution-independent. To convert to pixels, multiply by the page width/height. ## Response Structure The `bounding_boxes` object in the extraction response contains: ```json theme={null} { "bounding_boxes": { "Footer": [], "Header": [], "Images": [], "Tables": [], "Text": [], "Title": [], "Page Number": [], "markdown_with_ids": "..." } } ``` Not all fields will be present in every response. The API only includes arrays for elements that were detected in the document. ### Markdown Fields | Field | Location | Description | | ------------------- | ----------------------- | ------------------------------------------------------------------------------- | | `markdown` | Top-level response | Clean markdown content without any ID attributes | | `markdown_with_ids` | Inside `bounding_boxes` | Markdown with `data-bb-*` ID attributes that link text to bounding box elements | Use `bounding_boxes.markdown_with_ids` when you need to correlate text positions with bounding boxes. Use the top-level `markdown` for clean content display or export. ## Example Response Here's a real example of the `bounding_boxes` object from a workbook with an embedded chart, with `figure_processing.show_images: true`: ```json theme={null} { "Images": [ { "id": "excel_image_1_1", "visual_type": "chart", "page_number": 1, "bounding_box": [], "image_url": "https://api.runpulse.com/results/13e3e75f-a89a-4d33-a391-e1a17127ab38/images/excel_image_1_1.png", "sheet_name": "Charts", "excel_range": "D2", "chart_type": "BarChart", "chart_title": "Revenue", "source_ranges": ["'Charts'!$A$2:$A$5", "'Charts'!$B$2:$B$5"], "description": "Bar chart showing revenue by quarter." } ], "Tables": [], "Text": [ { "id": "txt-2", "content": "0a-NCRI", "original_content": "NCRI", "bounding_box": [0.0267, 0.0872, 0.0689, 0.0789, 0.0743, 0.0908, 0.0321, 0.0996], "page_number": 1, "average_word_confidence": 0.973 } ], "Title": [ { "id": "txt-1", "content": "0a-Doctor Prescription", "original_content": "Doctor Prescription", "bounding_box": [0.2196, 0.1225, 0.4578, 0.1348, 0.4557, 0.1537, 0.2174, 0.1417], "page_number": 1, "average_word_confidence": 0.995 } ] } ``` ## Field Descriptions ### Text Array Each text element contains: * `id`: Unique identifier (e.g., `txt-1`) that links to `markdown_with_ids` via `data-bb-text-id` * `content`: The extracted text with prefix (e.g., `0a-NCRI`) * `original_content`: The clean extracted text without prefix * `bounding_box`: 8-point coordinate array (may be `null` for some document types) * `page_number`: Page where the text appears * `average_word_confidence`: OCR confidence score (0-1) * `selected`: Selection state when `detect_selections` was enabled and the item represents a detected form control or marked-choice region ### Title Array Each title element contains: * `id`: Unique identifier linking to markdown * `content`: The title text with prefix * `original_content`: The clean title text * `bounding_box`: 8-point coordinate array * `page_number`: Page where the title appears * `average_word_confidence`: OCR confidence score (0-1) ### Header Array Each header element contains: * `id`: Unique identifier linking to markdown * `content`: The header text with prefix * `original_content`: The clean header text * `bounding_box`: 8-point coordinate array * `page_number`: Page where the header appears * `average_word_confidence`: OCR confidence score (0-1) ### Footer Array Each footer element contains: * `id`: Unique identifier linking to markdown * `content`: The footer text with prefix * `original_content`: The clean footer text * `bounding_box`: 8-point coordinate array * `page_number`: Page where the footer appears * `average_word_confidence`: OCR confidence score (0-1) ### Images Array Each image element represents a detected chart or embedded image. For PDFs and image inputs, entries are populated when figure detection runs. For spreadsheets, entries are populated for embedded charts and images directly read from the workbook. | Field | When populated | Description | | ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | always | Stable visual identifier (e.g. `excel_image_1_1`, `fig-3`). Joins to the `data-bb-image-id` attribute in `markdown_with_ids`. | | `visual_type` | always | `"chart"` (data visualization) or `"image"` (non-chart embedded/detected visual). | | `page_number` | always | 1-indexed page or sheet number. | | `bounding_box` | PDFs/images | 8-point coordinate polygon. Empty array for spreadsheets — use `excel_range` instead. | | `image_url` | when `figure_processing.show_images: true` | Pulse-hosted URL for the visual image bytes. Fetch via [`results.getImage`](/api-reference/endpoint/results-image) or any HTTP client with your API key. | | `description` | when `figure_processing.description: true` | LLM-generated 1–2 sentence caption. | | `content` | usually for spreadsheets | Short caption (e.g. `Chart: Revenue`). | | `sheet_name` | spreadsheets | Workbook sheet the visual lives on. | | `sheet_index` | spreadsheets | Parsed sheet index after hidden-sheet filtering. | | `workbook_sheet_index` | spreadsheets | Original workbook sheet index. | | `excel_range` | spreadsheets | Anchor cell or covered cell range (e.g. `D2:K18`). | | `chart_type` | spreadsheet charts | Chart class name (e.g. `BarChart`, `LineChart`, `PieChart`). | | `chart_title` | spreadsheet charts | Detected chart title text. | | `source_ranges` | spreadsheet charts | Cell ranges feeding the chart (e.g. `["Charts!$B$1:$B$3"]`). | | `classification` | optional | `{confidence, model, error}` when classification ran. | | `render_error` | optional | Non-fatal rendering error for spreadsheet visuals. When set, the entry is still returned but `image_url` may be omitted. | | `description_error` | optional | Non-fatal description-generation error. | ## Fetching Visual Image Bytes When you set `figure_processing.show_images: true` on `/extract`, every chart/image entry comes back with an `image_url` pointing at [`GET /results/{jobId}/images/{filename}`](/api-reference/endpoint/results-image). Fetch it with your API key to get the raw PNG/JPEG bytes: ```python Python theme={null} import re from pulse import Pulse from pulse.types import ExtractRequestFigureProcessing client = Pulse(api_key="YOUR_API_KEY") response = client.extract( file=open("financials.xlsx", "rb"), figure_processing=ExtractRequestFigureProcessing(show_images=True), ) for img in response.bounding_boxes.images or []: m = re.search(r"/results/([^/]+)/images/([^/?#]+)", img.image_url) job_id, filename = m.group(1), m.group(2) chunks = list(client.results.get_image(job_id=job_id, filename=filename)) with open(filename, "wb") as f: f.write(b"".join(chunks)) ``` ```typescript TypeScript theme={null} import { PulseClient } from "pulse-ts-sdk"; const client = new PulseClient({ apiKey: "YOUR_API_KEY" }); const response = await client.extract({ file: fs.createReadStream("financials.xlsx"), figureProcessing: { showImages: true }, }); for (const img of response.boundingBoxes?.Images ?? []) { const m = img.imageUrl?.match(/\/results\/([^/]+)\/images\/([^/?#]+)/); if (!m) continue; const [, jobId, filename] = m; const image = await client.results.getImage({ jobId, filename }); // Persist `image` per your runtime. } ``` See [Get Result Image](/api-reference/endpoint/results-image) for the full auth contract — visual image fetches always require same-org `x-api-key` authentication; there is no anonymous access. ### Tables Array Each table element contains: * `id`: Unique identifier (e.g., `tbl-1`) * `bounding_box`: 8-point coordinate array * `page_number`: Page where the table appears * `content`: Table content (in HTML format) ### Page Number Array Each page number element contains: * `id`: Unique identifier * `content`: The page number text * `original_content`: The clean page number text * `bounding_box`: 8-point coordinate array * `page_number`: Page where it appears * `average_word_confidence`: OCR confidence score (0-1) Tables are extracted and returned in HTML format, preserving the structure and making it easy to parse or display. The `id` field allows you to link bounding box elements to specific locations in the `markdown_with_ids` field via `data-bb-text-id` attributes. ## Footnote References When you enable `extensions.footnote_references` in your extract request, the response includes an `extensions.footnoteReferences` array that uses bounding box IDs to link footnote markers to their in-text references. Each entry contains: * `symbol` — the footnote marker (e.g. `*`, `†`, `‡`, `1`) * `footnoteTextId` — the `id` of the footnote explanation, typically found in the `Footer` array * `referenceTextIds` — an array of `id` values from the `Text`, `Title`, or `Header` arrays identifying body paragraphs that contain the marker ```json theme={null} { "extensions": { "footnoteReferences": [ { "symbol": "*", "footnoteTextId": "txt-42", "referenceTextIds": ["txt-5", "txt-12"] } ] } } ``` Use `footnoteTextId` to look up the footnote's position and content in `bounding_boxes.Footer` (or `bounding_boxes.Text`), and each entry in `referenceTextIds` to locate the citing paragraphs in `bounding_boxes.Text`, `bounding_boxes.Title`, or `bounding_boxes.Header`. This allows you to spatially highlight both the footnote and every place in the document that references it. Footnote references are only available for PDF documents. See the [Extract endpoint](/api-reference/endpoint/extract#footnote-references) for usage examples. ## Converting Coordinates To convert normalized coordinates to pixel coordinates: ```python theme={null} def normalize_to_pixels(bbox, page_width, page_height): """Convert normalized bounding box to pixel coordinates.""" return [ bbox[0] * page_width, # x1 bbox[1] * page_height, # y1 bbox[2] * page_width, # x2 bbox[3] * page_height, # y2 bbox[4] * page_width, # x3 bbox[5] * page_height, # y3 bbox[6] * page_width, # x4 bbox[7] * page_height # y4 ] # Example: Convert for a standard letter-size page at 72 DPI page_width = 612 # 8.5 inches * 72 DPI page_height = 792 # 11 inches * 72 DPI normalized_bbox = [0.1, 0.1, 0.3, 0.1, 0.3, 0.15, 0.1, 0.15] pixel_bbox = normalize_to_pixels(normalized_bbox, page_width, page_height) ``` ## Next Steps Enable bounding box extraction Combine with structured data # Batch Processing Source: https://docs.runpulse.com/api-reference/endpoint/batch-overview Process multiple documents through the Pulse pipeline in parallel ## Overview The batch endpoints let you run any step of the Pulse pipeline across many documents at once. Each batch call is fully asynchronous — it returns immediately with an ID to poll, and orchestrates parallel workers behind the scenes. `/batch/schema`, `/batch/tables`, and `/batch/split` always return a `batch_job_id`, polled via [GET /job/batch\_job\_id](/api-reference/endpoint/poll) for real-time progress including per-item completion status and individual child job IDs. `/batch/extract` returns one of two different ID fields depending on which request shape you use — see below. Batch endpoints mirror the individual pipeline steps. Each child call goes through the exact same code path as calling the individual endpoint directly — batch is orchestration, not a separate implementation. **`POST /batch/extract` accepts two request shapes at the same path**, and Pulse picks the right one automatically based on your request body: * **Backward-compatible** — `input`/`output`/`extract_options`/`workers`, the original contract. Returns `batch_job_id`; poll via `GET /job/{'{'}id{'}'}`. * **v2 (URL list)** — a top-level `urls` array. Backed by a durable, distributed queue built for very large batches (up to \~1,000,000 documents). Returns `batch_id`; poll via `GET /batch/{'{'}id{'}'}` for O(1) status, with pagination, cancellation, and a downloadable result manifest. `/batch/schema`, `/batch/tables`, and `/batch/split` are unaffected by this — they keep the original contract described below, and accept a `batch_job_id` from either shape via `batch_extract_id`. ### Pipeline Batch endpoints can be chained together, just like their single-document counterparts: ```mermaid theme={null} flowchart LR A["Batch Extract"] --> B["Batch Schema"] A --> C["Batch Tables"] A --> D["Batch Split"] D --> E["Batch Schema\n(split mode)"] ``` Each step takes the output of a previous step as input, either via a `batch_extract_id` / `batch_split_id` that references the parent batch job, or via an explicit list of individual IDs. ### Workers Workers process items in parallel. You can control concurrency with the `workers` parameter on every batch endpoint. | Parameter | Type | Default | Max | Description | | --------- | ------- | ------- | --- | -------------------------- | | `workers` | integer | 4 | 10 | Number of parallel workers | *** ## Batch Extract Enumerate files from an input source and extract content from each one. `POST /batch/extract` accepts either request shape below at the same path — Pulse detects which one you're using from the request body (presence of a top-level `urls` array selects v2; everything else falls through to the backward-compatible contract). See [Extract](/api-reference/endpoint/extract) for details on `extract_options` (pages, figure processing, extensions, etc.). ### Request — `POST /batch/extract` (backward-compatible) | Field | Type | Required | Description | | ----------------- | ------- | -------- | ---------------------------------------------------------------------------------- | | `input` | object | Yes | Source of files to process (see [Input Sources](#input-sources)) | | `output` | object | Yes | Where to save extraction results (see [Output Destinations](#output-destinations)) | | `extract_options` | object | No | Options forwarded to each `/extract` call | | `workers` | integer | No | Parallel workers (default: 4, max: 10) | Multipart file uploads (`-F "file=@..."`) are also accepted at this same path instead of a JSON body — see [Input Sources](#input-sources). ### Response (202) — backward-compatible | Field | Type | Description | | -------------- | ------- | -------------------------------------------------- | | `batch_job_id` | string | Job ID for [polling](/api-reference/endpoint/poll) | | `status` | string | `"processing"` | | `total_files` | integer | Number of files that will be processed | The `batch_job_id` from a batch extract can be used in two ways: * **Batch Schema** — Pass it as `batch_extract_id` to `POST /batch/schema` to apply the same schema to each document independently (one result per document). * **Multi-Extraction** — Pass it as `extraction_id` to `POST /schema` to combine all documents and apply the schema to the composite (one merged result). See [Multi-Extraction Mode](/api-reference/endpoint/schema#multi-extraction). ### Request — `POST /batch/extract` (v2, URL list) Built for very large batches (up to \~1,000,000 documents) via a durable, distributed queue rather than in-process workers. URL-only — no S3 prefix, inline base64, or multipart upload input. | Field | Type | Required | Description | | ------------------- | ---------------- | -------- | -------------------------------------------------------------------------------------- | | `urls` | array of strings | Yes | Non-empty list of document URLs to download and process | | `priority` | string | No | Accepted and stored on the batch; not yet used for queue routing (default: `"normal"`) | | *(any other field)* | — | No | Passed through as extract options to every child, same as `extract_options` above | There is no separate `output` field for this shape — results are managed by Pulse and retrieved via the result manifest (see [Batch Status](#batch-status-v2) below), not written to a caller-specified destination. ### Response (202) — v2 | Field | Type | Description | | ------------- | ------- | ------------------------------------------------------------------------------------ | | `batch_id` | string | Batch ID — poll via `GET /batch/{'{'}batch_id{'}'}`, **not** `GET /job/{'{'}id{'}'}` | | `status` | string | `"processing"` | | `total_files` | integer | Number of URLs that will be processed | ### Example -- S3 Source ```python Python theme={null} from pulse import Pulse from pulse.types.batch_input_source import BatchInputSource from pulse.types.batch_output_destination import BatchOutputDestination client = Pulse(api_key="YOUR_API_KEY") resp = client.batch.extract( input=BatchInputSource(s_3_prefix="s3://my-bucket/documents/"), output=BatchOutputDestination(s_3_prefix="s3://my-bucket/results/extractions/"), workers=10, ) print(f"Job: {resp.batch_job_id}, files: {resp.total_files}") ``` ```typescript TypeScript theme={null} import { PulseClient } from "pulse-ts-sdk"; const client = new PulseClient({ apiKey: "YOUR_API_KEY" }); const resp = await client.batch.extract({ input: { s3_prefix: "s3://my-bucket/documents/" }, output: { s3_prefix: "s3://my-bucket/results/extractions/" }, workers: 10, }); console.log(`Job: ${resp.batch_job_id}, files: ${resp.total_files}`); ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/batch/extract \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "input": { "s3_prefix": "s3://my-bucket/documents/" }, "output": { "s3_prefix": "s3://my-bucket/results/extractions/" }, "workers": 10 }' ``` ### Example -- Upload Files from Folder ```python Python theme={null} import base64 import os from pulse.types import BatchFileUpload # Upload and extract all files from a local folder folder = "documents/" files = [] for name in os.listdir(folder): with open(os.path.join(folder, name), "rb") as f: files.append(BatchFileUpload( filename=name, content=base64.b64encode(f.read()).decode(), )) resp = client.batch.extract( input=BatchInputSource(files=files), output=BatchOutputDestination(s_3_prefix="s3://my-bucket/results/"), ) ``` ```typescript TypeScript theme={null} import * as fs from "fs"; import * as path from "path"; // Upload and extract all files from a local folder const folder = "documents/"; const files = fs.readdirSync(folder).map(name => ({ filename: name, content: fs.readFileSync(path.join(folder, name)).toString("base64"), })); const resp = await client.batch.extract({ input: { files }, output: { s3Prefix: "s3://my-bucket/results/" }, }); ``` ```bash curl theme={null} # Upload files directly as multipart form data curl -X POST https://api.runpulse.com/batch/extract \ -H "x-api-key: YOUR_API_KEY" \ -F "file=@documents/report.pdf" \ -F "file=@documents/data.xlsx" \ -F 'output={"s3_prefix":"s3://my-bucket/results/"}' ``` ### Example -- v2 URL List (large batches) This is the URL-only v2 shape — a top-level `urls` array selects it automatically. Shown here as `curl`; check the SDK changelog for native `urls=` support in your language. ```bash curl theme={null} curl -X POST https://api.runpulse.com/batch/extract \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "urls": [ "https://example.com/report-1.pdf", "https://example.com/report-2.pdf" ], "priority": "normal" }' # Response (202): # { "batch_id": "b2232707-3975-4e12-b815-b907fe0f653d", "status": "processing", "total_files": 2 } ``` *** ## Batch Schema Apply the **same schema to each document independently**, producing one result per extraction. Supports two modes, inferred from input: * **Single mode** — Provide `extraction_ids` or `batch_extract_id` with `schema_config` * **Split mode** — Provide `split_ids` or `batch_split_id` with `split_schema_config` **Batch Schema vs Multi-Extraction** — these solve different problems: * **Batch Schema** (`POST /batch/schema`) applies the same schema to each document **separately** — you get N results for N documents. * **Multi-Extraction** (`POST /schema` with a batch extract ID or `extraction_ids`) **combines** all documents into one composite and applies the schema once — you get a single result with data merged from all sources. If you need to pull data that spans across multiple files (e.g., loss data in one file + exposure data in another), use [Multi-Extraction](/api-reference/endpoint/schema#multi-extraction) instead. See [Schema](/api-reference/endpoint/schema) for details on `schema_config`, `split_schema_config`, and the difference between single and split modes. ### Request — `POST /batch/schema` | Field | Type | Required | Description | | --------------------- | ------- | ----------- | ---------------------------------------------- | | `output` | object | Yes | Where to save schema results | | `batch_extract_id` | string | XOR | ID of a prior batch extract run (single mode) | | `extraction_ids` | array | XOR | Explicit list of extraction IDs (single mode) | | `batch_split_id` | string | XOR | ID of a prior batch split run (split mode) | | `split_ids` | array | XOR | Explicit list of split IDs (split mode) | | `schema_config` | object | Conditional | Schema configuration for single mode | | `split_schema_config` | object | Conditional | Per-topic schema configurations for split mode | | `workers` | integer | No | Parallel workers (default: 4, max: 10) | ### Response (202) | Field | Type | Description | | ------------------- | ------- | -------------------------------------------------- | | `batch_job_id` | string | Job ID for [polling](/api-reference/endpoint/poll) | | `status` | string | `"processing"` | | `total_extractions` | integer | Number of extractions to process (single mode) | | `total_splits` | integer | Number of splits to process (split mode) | ### Example — Single Mode ```python Python theme={null} from pulse.types.schema_config import SchemaConfig resp = client.batch.schema( batch_extract_id="", output=BatchOutputDestination(s_3_prefix="s3://my-bucket/results/schemas/"), schema_config=SchemaConfig( input_schema={ "type": "object", "properties": { "total_amount": {"type": "number"}, "vendor_name": {"type": "string"}, }, }, schema_prompt="Extract invoice details", ), workers=10, ) ``` ```typescript TypeScript theme={null} const resp = await client.batch.schema({ batchExtractId: "", output: { s3_prefix: "s3://my-bucket/results/schemas/" }, schemaConfig: { inputSchema: { type: "object", properties: { total_amount: { type: "number" }, vendor_name: { type: "string" }, }, }, schemaPrompt: "Extract invoice details", }, workers: 10, }); ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/batch/schema \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "batch_extract_id": "", "output": { "s3_prefix": "s3://my-bucket/results/schemas/" }, "schema_config": { "input_schema": { "type": "object", "properties": { "total_amount": {"type": "number"}, "vendor_name": {"type": "string"} } }, "schema_prompt": "Extract invoice details" }, "workers": 10 }' ``` ### Example — Split Mode ```python Python theme={null} from pulse.types.topic_schema_config import TopicSchemaConfig resp = client.batch.schema( batch_split_id="", output=BatchOutputDestination(s_3_prefix="s3://my-bucket/results/schema-splits/"), split_schema_config={ "financial_statements": TopicSchemaConfig( schema_={"type": "object", "properties": {"revenue": {"type": "number"}}}, schema_prompt="Extract financial data", ), "risk_factors": TopicSchemaConfig( schema_={"type": "object", "properties": {"risks": {"type": "array", "items": {"type": "string"}}}}, ), }, workers=10, ) ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/batch/schema \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "batch_split_id": "", "output": { "s3_prefix": "s3://my-bucket/results/schema-splits/" }, "split_schema_config": { "financial_statements": { "schema": {"type": "object", "properties": {"revenue": {"type": "number"}}}, "schema_prompt": "Extract financial data" }, "risk_factors": { "schema": {"type": "object", "properties": {"risks": {"type": "array", "items": {"type": "string"}}}} } }, "workers": 10 }' ``` *** ## Batch Tables Extract tables from multiple existing extractions. See [Tables](/api-reference/endpoint/tables) for details on `tables_config` (merge, table format, etc.). ### Request — `POST /batch/tables` | Field | Type | Required | Description | | ------------------ | ------- | -------- | -------------------------------------- | | `output` | object | Yes | Where to save table results | | `batch_extract_id` | string | XOR | ID of a prior batch extract run | | `extraction_ids` | array | XOR | Explicit list of extraction IDs | | `tables_config` | object | No | Table extraction configuration | | `workers` | integer | No | Parallel workers (default: 4, max: 10) | ### Response (202) | Field | Type | Description | | ------------------- | ------- | -------------------------------------------------- | | `batch_job_id` | string | Job ID for [polling](/api-reference/endpoint/poll) | | `status` | string | `"processing"` | | `total_extractions` | integer | Number of extractions to process | ### Example ```python Python theme={null} resp = client.batch.tables( batch_extract_id="", output=BatchOutputDestination(s_3_prefix="s3://my-bucket/results/tables/"), tables_config=TablesConfig(merge=True, table_format="html"), workers=10, ) ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/batch/tables \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "batch_extract_id": "", "output": { "s3_prefix": "s3://my-bucket/results/tables/" }, "tables_config": { "merge": true, "table_format": "html" }, "workers": 10 }' ``` *** ## Batch Split Split multiple extractions into topics. See [Split](/api-reference/endpoint/split) for details on `split_config` (topic definitions with names and descriptions). ### Request — `POST /batch/split` | Field | Type | Required | Description | | ------------------ | ------- | -------- | ------------------------------------------ | | `output` | object | Yes | Where to save split results | | `split_config` | object | Yes | Split configuration with topic definitions | | `batch_extract_id` | string | XOR | ID of a prior batch extract run | | `extraction_ids` | array | XOR | Explicit list of extraction IDs | | `workers` | integer | No | Parallel workers (default: 4, max: 10) | ### Response (202) | Field | Type | Description | | ------------------- | ------- | -------------------------------------------------- | | `batch_job_id` | string | Job ID for [polling](/api-reference/endpoint/poll) | | `status` | string | `"processing"` | | `total_extractions` | integer | Number of extractions to process | ### Example ```python Python theme={null} from pulse.types.split_config import SplitConfig from pulse.types.topic_definition import TopicDefinition resp = client.batch.split( batch_extract_id="", output=BatchOutputDestination(s_3_prefix="s3://my-bucket/results/splits/"), split_config=SplitConfig( split_input=[ TopicDefinition(name="financial_statements", description="Balance sheets, income statements"), TopicDefinition(name="risk_factors", description="Risk factors and forward-looking statements"), ], ), workers=10, ) ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/batch/split \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "batch_extract_id": "", "output": { "s3_prefix": "s3://my-bucket/results/splits/" }, "split_config": { "split_input": [ {"name": "financial_statements", "description": "Balance sheets, income statements"}, {"name": "risk_factors", "description": "Risk factors and forward-looking statements"} ] }, "workers": 10 }' ``` *** ## Input and Output ### Input Sources Batch Extract accepts one of the following input sources via JSON body: | Source | Field | Description | | ------------------- | ----------------- | ------------------------------------------------------------------------------------------------------ | | S3 prefix | `input.s3_prefix` | S3 URI prefix (e.g. `s3://my-bucket/documents/`). All supported files under this prefix are processed. | | URL list | `input.file_urls` | Explicit list of file URLs to download and process. | | Inline file uploads | `input.files` | Array of `{ filename, content }` objects where `content` is base64-encoded file bytes. | Alternatively, you can upload files directly via **multipart form data** instead of a JSON body: ```bash theme={null} curl -X POST https://api.runpulse.com/batch/extract \ -H "x-api-key: YOUR_API_KEY" \ -F "file=@report.pdf" \ -F "file=@data.xlsx" \ -F 'output={"s3_prefix":"s3://my-bucket/results/"}' ``` All other batch endpoints reference prior results via IDs rather than raw files. ### Output Destinations Every batch endpoint writes results to an output destination. You can specify one or both: | Destination | Field | Example | | --------------- | ------------ | ------------------------- | | S3 prefix | `s3_prefix` | `s3://my-bucket/results/` | | Local directory | `local_path` | `/data/results/` | *** ## Monitoring Progress This section covers the **backward-compatible** shape (`batch_job_id`). If you created your batch with the v2 `urls` request (see [above](#batch-extract)), see [Batch Status (v2)](#batch-status-v2) below instead — polling via `GET /job/{'{'}id{'}'}` does not work for a v2 `batch_id`. Poll [GET /job/batch\_job\_id](/api-reference/endpoint/poll) to monitor a batch job. The response includes a `result` object with structured progress: ```json theme={null} { "status": "processing", "result": { "progress": { "total": 10, "completed": 7, "failed": 1 }, "jobs": { "completed": [ { "job_id": "abc-123", "file": "report.pdf" } ], "failed": [ { "job_id": "def-456", "file": "corrupt.pdf", "error": "..." } ], "processing": ["ghi-789"] } } } ``` Each child `job_id` can be polled individually for detailed results. ### Polling Example ```python Python theme={null} import time job_id = resp.batch_job_id while True: job = client.jobs.get_job(job_id=job_id) if job.status in ("completed", "failed", "canceled"): print(f"Final status: {job.status}") break if job.result and "progress" in job.result: p = job.result["progress"] print(f"{p['completed']}/{p['total']} completed, {p['failed']} failed") time.sleep(5) ``` ```typescript TypeScript theme={null} let job = await client.jobs.getJob(resp.batchJobId); while (job.status !== "completed" && job.status !== "failed" && job.status !== "canceled") { await new Promise(r => setTimeout(r, 5000)); job = await client.jobs.getJob(resp.batchJobId); if (job.result?.progress) { const p = job.result.progress; console.log(`${p.completed}/${p.total} completed, ${p.failed} failed`); } } console.log(`Final status: ${job.status}`); ``` *** ## Batch Status (v2) For batches created with the [v2 `urls` request](#request--post-batchextract-v2-url-list), status is O(1) regardless of batch size — no need to page through per-item results just to check overall progress. ### `GET /batch/{'{'}batch_id{'}'}` | Field | Type | Description | | --------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `batch_id` | string | The batch ID | | `status` | string | `"processing"`, `"completed"`, `"failed"`, or `"canceled"` | | `priority` | string | As submitted (default `"normal"`) | | `created_at` | string | ISO timestamp | | `counts` | object | `total`, `pending`, `processing`, `succeeded`, `failed`, `canceled` | | `jobs_url` | string | Path to the paginated per-document drill-down, see below | | `result_manifest_url` | string \| null | Downloadable NDJSON manifest of every child's outcome; built lazily on the first poll after the batch reaches a terminal status, so this stays `null` for a few polls after completion | ```bash curl theme={null} curl https://api.runpulse.com/batch/b2232707-3975-4e12-b815-b907fe0f653d \ -H "x-api-key: YOUR_API_KEY" # { # "batch_id": "b2232707-3975-4e12-b815-b907fe0f653d", # "status": "processing", # "priority": "normal", # "created_at": "2026-08-04T12:00:00Z", # "counts": { "total": 1000, "pending": 400, "processing": 80, "succeeded": 510, "failed": 10, "canceled": 0 }, # "jobs_url": "/batch/b2232707-3975-4e12-b815-b907fe0f653d/jobs", # "result_manifest_url": null # } ``` ### `GET /batch/{'{'}batch_id{'}'}/jobs?status=&cursor=` Paginated per-document drill-down (100 per page). `status` optionally filters to one of `pending`, `processing`, `completed`, `failed`, `canceled`. Keep following `next_cursor` until it's `null`. | Field | Type | Description | | ------------- | -------------- | ---------------------------------------------------------------- | | `jobs` | array | `{ job_id, status, result, error }` per document on this page | | `next_cursor` | string \| null | Pass back as `?cursor=` for the next page; `null` when exhausted | ```bash curl theme={null} curl "https://api.runpulse.com/batch/b2232707-3975-4e12-b815-b907fe0f653d/jobs?status=failed" \ -H "x-api-key: YOUR_API_KEY" ``` *** ## Cancellation This is the **backward-compatible** cancellation path. For a v2 `batch_id`, use `POST /batch/{'{'}batch_id{'}'}/cancel` below instead. Cancel a batch job with [DELETE /job/batch\_job\_id](/api-reference/endpoint/cancel). This cascades to all child jobs that are still pending or processing. ### `POST /batch/{'{'}batch_id{'}'}/cancel` (v2) Pending children are canceled outright; children already `processing` run to natural completion rather than being force-killed. ```bash curl theme={null} curl -X POST https://api.runpulse.com/batch/b2232707-3975-4e12-b815-b907fe0f653d/cancel \ -H "x-api-key: YOUR_API_KEY" # { "batch_id": "b2232707-3975-4e12-b815-b907fe0f653d", "status": "canceled" } ``` *** ## Full Pipeline Example Process a folder of SEC filings: extract all files, apply a schema, extract tables, split by topic, and apply per-topic schemas. ```python Python theme={null} from pulse import Pulse from pulse.types.batch_input_source import BatchInputSource from pulse.types.batch_output_destination import BatchOutputDestination from pulse.types.schema_config import SchemaConfig from pulse.types.split_config import SplitConfig from pulse.types.tables_config import TablesConfig from pulse.types.topic_definition import TopicDefinition from pulse.types.topic_schema_config import TopicSchemaConfig import time client = Pulse(api_key="YOUR_API_KEY") def wait_for_job(job_id: str) -> dict: while True: job = client.jobs.get_job(job_id=job_id) if job.status in ("completed", "failed", "canceled"): return {"status": job.status, "result": job.result} time.sleep(5) # Step 1: Batch Extract extract = client.batch.extract( input=BatchInputSource(s_3_prefix="s3://my-bucket/10-K/"), output=BatchOutputDestination(s_3_prefix="s3://my-bucket/results/extractions/"), workers=10, ) extract_result = wait_for_job(extract.batch_job_id) # Step 2: Batch Schema schema = client.batch.schema( batch_extract_id=extract.batch_job_id, output=BatchOutputDestination(s_3_prefix="s3://my-bucket/results/schemas/"), schema_config=SchemaConfig( input_schema={"type": "object", "properties": {"revenue": {"type": "number"}}}, ), workers=10, ) wait_for_job(schema.batch_job_id) # Step 3: Batch Tables tables = client.batch.tables( batch_extract_id=extract.batch_job_id, output=BatchOutputDestination(s_3_prefix="s3://my-bucket/results/tables/"), tables_config=TablesConfig(merge=True, table_format="html"), workers=10, ) wait_for_job(tables.batch_job_id) # Step 4: Batch Split split = client.batch.split( batch_extract_id=extract.batch_job_id, output=BatchOutputDestination(s_3_prefix="s3://my-bucket/results/splits/"), split_config=SplitConfig(split_input=[ TopicDefinition(name="financials", description="Financial statements"), TopicDefinition(name="risk_factors", description="Risk disclosures"), ]), workers=10, ) wait_for_job(split.batch_job_id) # Step 5: Batch Schema (split mode) split_schema = client.batch.schema( batch_split_id=split.batch_job_id, output=BatchOutputDestination(s_3_prefix="s3://my-bucket/results/schema-splits/"), split_schema_config={ "financials": TopicSchemaConfig( schema_={"type": "object", "properties": {"revenue": {"type": "number"}}}, ), "risk_factors": TopicSchemaConfig( schema_={"type": "object", "properties": {"risks": {"type": "array", "items": {"type": "string"}}}}, ), }, workers=10, ) wait_for_job(split_schema.batch_job_id) ``` *** ## Related Endpoints Individual file extraction — config options apply to Batch Extract Single/split schema extraction — config options apply to Batch Schema Table extraction — config options apply to Batch Tables Topic splitting — config options apply to Batch Split Poll batch job progress Cancel a batch job and all child jobs # Cancel Job (Deprecated) Source: https://docs.runpulse.com/api-reference/endpoint/cancel POST /cancel/{job_id} # Charts Source: https://docs.runpulse.com/api-reference/endpoint/charts POST /charts Detect and digitize charts from a completed extraction or split. The response includes normalized source bounding boxes, reconstructed data series, axis metadata, confidence scores, and optional export files. Provide exactly one of `extraction_id` or `split_id`. When using an extraction, `page_range` can limit processing to selected 1-indexed pages. Split requests inherit their pages from the split and therefore cannot also provide `page_range`. Export URLs require authentication and are consumed after one successful download. Request every required format up front and store the downloaded bytes rather than the URL. Set `async: true` to return immediately with a `job_id`. Poll `GET /job/{job_id}` for the completed `ChartsResponse`. ## Overview **Pipeline Step 2 (terminal)** — Charts reuses a completed extraction or split. It does not accept a document upload directly. Reconstruct line, scatter, bar, pie, donut, and well-log charts as auditable data. Each result contains the source page and normalized bounding box, reconstructed series, axis metadata, a confidence score, and warnings. You can also request Excel, CSV, or LAS exports. The endpoint can run synchronously or asynchronously and is billed at **1 credit per reconstructed chart**. It must be enabled for your organization. ## Request Provide exactly one source: | Field | Type | Required | Description | | --------------- | ------- | ---------- | --------------------------------------------------------------------------------------- | | `extraction_id` | UUID | One source | A completed, saved extraction. | | `split_id` | UUID | One source | A completed split. Results are grouped by split topic. | | `page_range` | string | No | Extraction mode only. 1-indexed pages such as `"1-3,5"`. Split mode inherits its pages. | | `charts_config` | object | No | Reconstruction and export options. | | `async` | boolean | No | Defaults to `false`. With `true`, returns a `job_id` immediately for polling. | ### `charts_config` | Field | Type | Default | Description | | ---------------- | -------------- | ----------- | -------------------------------------------------------------------------------------------------------- | | `data_points` | integer, 2–500 | `20` | Requested samples per continuous series. | | `agentic_zoom` | boolean | `false` | Inspect difficult regions at higher resolution. This can improve recovery at additional latency. | | `export_formats` | array | `["excel"]` | Any of `excel`, `csv`, and `las`. Use `[]` to disable exports. LAS applies only to compatible well logs. | Options belong inside `charts_config`. Top-level `data_points`, `agentic_zoom`, `export_formats`, `generate_las`, and `layout_confidence` are rejected. ```bash theme={null} curl -X POST https://api.runpulse.com/charts \ -H "x-api-key: $PULSE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "extraction_id": "0d904c3c-8eb8-4abd-a455-04c3a83c5b43", "page_range": "1-3,5", "async": true, "charts_config": { "data_points": 100, "agentic_zoom": true, "export_formats": ["excel", "csv"] } }' ``` For a split, replace `extraction_id` with `split_id` and omit `page_range`. ## Response With `async: true`, the endpoint returns HTTP `202`: ```json theme={null} { "job_id": "90705060-b7b9-48c3-9593-7c7d62ce280e", "status": "pending", "message": "Chart extraction started. Poll GET /job/{job_id} for results." } ``` Poll `GET /job/{job_id}` until the job reaches a terminal status. The completed chart response is returned in the job response's `result` field. `charts_id` belongs to that completed result; it is not duplicated in the initial async acknowledgement. Extraction mode returns a flat `charts` array. Split mode returns `results`, keyed by topic, with each topic's inherited pages and charts. `count` is the total number of charts across the response. ```json theme={null} { "charts_id": "90705060-b7b9-48c3-9593-7c7d62ce280e", "extraction_id": "0d904c3c-8eb8-4abd-a455-04c3a83c5b43", "page_range": "1-3,5", "count": 1, "charts": [ { "id": "fig-1", "page_number": 2, "bounding_box": [ {"x": 0.12, "y": 0.18}, {"x": 0.88, "y": 0.18}, {"x": 0.88, "y": 0.76}, {"x": 0.12, "y": 0.76} ], "type": "line", "title": "Monthly price", "x_axis": {"type": "categorical", "title": "Month"}, "y_axis": {"type": "linear", "title": "Price"}, "series": [ { "name": "Series 1", "color": "#1769aa", "line_style": "solid", "data": [["Jan", 102.4], ["Feb", 105.1]] } ], "confidence": 0.94, "warnings": [] } ], "exports": [ { "format": "excel", "filename": "charts.xlsx", "url": "https://api.runpulse.com/results/90705060-b7b9-48c3-9593-7c7d62ce280e/charts/charts.xlsx" } ], "excel_url": "https://api.runpulse.com/results/90705060-b7b9-48c3-9593-7c7d62ce280e/charts/charts.xlsx", "credits_used": 1 } ``` `excel_url` is a compatibility alias. New integrations should iterate over `exports`. If an export cannot be produced, `export_warnings` explains why without failing the chart reconstruction. ## Export Downloads Every export URL: * requires the same API key as the chart request; * belongs to the organization that created the chart result; * is deleted after one successful stream; * must not be prefetched by an agent, UI, or link preview. ```bash theme={null} curl -H "x-api-key: $PULSE_API_KEY" \ "https://api.runpulse.com/results/90705060-b7b9-48c3-9593-7c7d62ce280e/charts/charts.xlsx" \ --output charts.xlsx ``` Store the downloaded file. Do not store the URL as a durable artifact reference. ## Retrieve Results Authenticated applications can hydrate saved views without rerunning reconstruction: | Endpoint | Purpose | | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `GET /api/v1/charts/{charts_id}` | Read one owned result by ID. | | `GET /api/v1/charts/extraction/{extraction_id}/latest` | Read the newest result associated with an extraction, including split and pipeline runs. | | `GET /api/v1/charts/extraction/{extraction_id}/public/latest` | Read the newest result only while that extraction's public sharing and retention windows are active. | The public route intentionally returns `404` for missing, private, expired, and deleted extractions so it does not disclose which condition failed. ## Accuracy Chart output is reconstructed from pixels and OCR, so treat values as estimates. Review `confidence` and `warnings`, and validate consequential data against the source using the returned `page_number` and `bounding_box`. `agentic_zoom` can help with small labels or dense curves, but does not guarantee exact source values. ## Related Create the saved extraction consumed by Charts. Reuse extraction and split IDs across downstream steps. # Classify Document Source: https://docs.runpulse.com/api-reference/endpoint/classify POST /classify Lightweight routing step that runs **before** `/extract`. Given a raw document (file upload or `file_url`) and a set of caller-defined classifications, it returns which classification the document belongs to — plus that classification's `pipeline_id`, so you can send the document to the right pipeline next. By default, it evaluates the first five pages. For PDFs and images, billing is based on the effective `page_range`. For Office and HTML files, billing applies to every page. Billed at **0.5 credits per page** — half the `/extract` rate. Accepted document types are identical to `/extract`, including the same size limits and URL validation. Set `async: true` to return immediately with a job_id for polling via GET /job/{jobId}. Otherwise processes synchronously. ## Overview **Routing step (optional, runs before extraction)** — Classify takes a **raw document**, not a saved extraction. Use it to decide which pipeline (and therefore which [`/extract`](/api-reference/endpoint/extract) settings) a document should be routed to. `POST /classify` is a lightweight routing step that runs **before** `/extract`. Given a raw document and a set of caller-defined classifications, it returns which classification the document belongs to — plus that classification's `pipeline_id`, so you can send the document to the right pipeline next. Why classify *before* extract: extraction settings (chunking, schema, tables, …) differ per document type, so you need to know the type before you extract. By default, `/classify` evaluates the first five pages and costs **0.5 credits per page** — half the `/extract` rate. See [Credits](#credits). ### Accepted Document Types `/classify` uses the same upload path as [`/extract`](/api-reference/endpoint/extract) — the same accepted formats, the same size limits, and both direct file uploads and `file_url`. | Category | Extensions | | -------- | --------------------------------------------------- | | PDF | `.pdf` | | Images | `.jpg`, `.jpeg`, `.png`, `.webp` | | Office | `.docx`, `.pptx`, `.xlsx`, `.xlsm`, `.xls`, `.xlsb` | | Data | `.csv` | | Web | `.html`, `.htm` | Formats that need normalizing (`.webp`, `.csv`, `.xlsb`) are converted on upload before classification, exactly as they are for `/extract`. How a format is handled affects billing, not acceptance: PDFs and images are billed only for the pages in the effective `page_range`, while Office and HTML files are billed for every page. See [Credits](#credits). ### Async Mode Set `async: true` to return immediately with a job ID for polling. See [Polling for Results](/api-reference/endpoint/poll). On completion, the job's result carries the same body as the sync response. `/classify` is currently REST-only: it is not yet exposed as a Python/TypeScript SDK method, a CLI command, or an MCP tool. Call it over HTTP as shown below. *** ## Request Provide **either** `file` (multipart upload) **or** `file_url` (JSON body) — not both. ### Request Body | Field | Type | Required | Description | | ----------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------- | | `file` | file | XOR | The document to classify (multipart/form-data upload). | | `file_url` | string | XOR | URL of the document to classify (application/json body). | | `classify_config` | object | Yes | Candidate classifications (JSON **string** when sent as a multipart form field). | | `page_range` | string | No | Pages to inspect, e.g. `"1-5"` or `"1,3,5-7"`. Default: first 5 pages, clamped to the document length. | | `async` | boolean | No | If `true`, returns immediately with a `job_id` for [polling](/api-reference/endpoint/poll). Default: `false`. | | `parent_job_id` | uuid | No | Optional parent job ID for tracking. | ### Classify Config (`classify_config`) ```jsonc theme={null} { "classifications": { "": { "description": "string, required — what documents belong to this class", "pipeline_id": "string, optional — pipeline to route matches to" } // ... one entry per candidate classification } } ``` * `` is any label you choose; Pulse returns exactly one of these names. * `description` tells Pulse what belongs in each class — make it specific and mutually distinct. * `pipeline_id` is **optional routing metadata**. When provided, it must reference a pipeline your organization owns (otherwise the request is rejected with `PIPELINE_003`). The matched classification's `pipeline_id` is echoed back so you can route the document next. ### Always include a catch-all classification **Classification is forced-choice.** `/classify` always returns one of the names you supplied — there is no built-in "none of the above", no `null` result, and no confidence score. A document that matches none of your classifications is not rejected: it is assigned to whichever class comes closest, with nothing in the response to flag it as a poor match. Define the escape hatch yourself: add a catch-all classification and **leave off its `pipeline_id`**. ```jsonc theme={null} { "classifications": { "bank_statement": { "description": "Bank or account statements: balances, transaction lists, deposits and withdrawals.", "pipeline_id": "b1a2c3d4-..." }, "invoice": { "description": "Invoices or bills requesting payment: line items, amounts due, payment terms.", "pipeline_id": "e5f6a7b8-..." }, "unrecognized": { // No pipeline_id — so classify_output.pipeline_id comes back null "description": "Any document that does not clearly match one of the other classifications — including unrelated document types, blank or illegible scans, and standalone cover pages." } } } ``` A `null` `classify_output.pipeline_id` then becomes your "don't extract this" signal: ```json theme={null} { "classification": "unrecognized", "classify_output": { "classification": "unrecognized", "pipeline_id": null, "page_range": "1-5" } } ``` Branch on it before you route — send those documents to manual review instead of into a pipeline built for a different document type. Give the catch-all a description that says what it absorbs, not just the word "other". A thin description competes poorly against your detailed classes, and Pulse will keep preferring a wrong-but-well-described class over a vague catch-all. For the same reason, keep the specific descriptions tight — a broad description like "financial documents" pulls in documents the catch-all should have caught. Without a catch-all there is no clean "no match" outcome — you get either a confidently wrong classification, or a `PROC_001` error in the case where Pulse answers off-list, since any name outside your `classifications` keys is rejected. *** ## Response ### Synchronous Response (200) | Field | Type | Description | | -------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `classification` | string | The winning classification name — always one of your `classifications` keys ([never a "no match" value](#always-include-a-catch-all-classification)) | | `classify_output.classification` | string | Same as `classification` | | `classify_output.pipeline_id` | uuid \| null | The matched classification's `pipeline_id`, or `null` if it had none — see [catch-all classifications](#always-include-a-catch-all-classification) | | `classify_output.page_range` | string | The effective page range used for classification (after defaulting/clamping) | | `job_id` | uuid | Identifier of the request | | `credits_used` | number | Credits deducted for this call | ### Async Response (202) | Field | Type | Description | | --------- | ------ | -------------------------------------------------- | | `job_id` | string | Job ID for [polling](/api-reference/endpoint/poll) | | `status` | string | `"pending"` | | `message` | string | Human-readable description | *** ## Example Usage ```bash curl (file upload) theme={null} curl -X POST https://api.runpulse.com/classify \ -H "x-api-key: YOUR_API_KEY" \ -F "file=@document.pdf" \ -F 'classify_config={ "classifications": { "bank_statement": { "description": "Bank or account statements: balances, transaction lists, deposits and withdrawals.", "pipeline_id": "b1a2c3d4-..." }, "invoice": { "description": "Invoices or bills requesting payment: line items, amounts due, payment terms.", "pipeline_id": "e5f6a7b8-..." }, "unrecognized": { "description": "Any document that does not clearly match one of the other classifications, including unrelated types and illegible scans." } } }' ``` ```bash curl (file URL) theme={null} curl -X POST https://api.runpulse.com/classify \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "file_url": "https://example.com/document.pdf", "page_range": "1-5", "classify_config": { "classifications": { "bank_statement": { "description": "Bank or account statements: balances, transaction lists.", "pipeline_id": "b1a2c3d4-..." }, "invoice": { "description": "Invoices or bills requesting payment.", "pipeline_id": "e5f6a7b8-..." }, "unrecognized": { "description": "Any document that does not clearly match one of the other classifications, including unrelated types and illegible scans." } } } }' ``` ```python Python theme={null} import json import requests resp = requests.post( "https://api.runpulse.com/classify", headers={"x-api-key": "YOUR_API_KEY"}, files={"file": open("document.pdf", "rb")}, data={ "classify_config": json.dumps({ "classifications": { "bank_statement": { "description": "Bank or account statements: balances, transaction lists.", "pipeline_id": "b1a2c3d4-...", }, "invoice": { "description": "Invoices or bills requesting payment.", "pipeline_id": "e5f6a7b8-...", }, "unrecognized": { "description": "Any document that does not clearly match one of the other classifications, including unrelated types and illegible scans.", }, } }) }, ) result = resp.json() print(result["classification"], result["classify_output"]["pipeline_id"]) ``` ### Example Response ```json theme={null} { "classification": "bank_statement", "classify_output": { "classification": "bank_statement", "pipeline_id": "b1a2c3d4-...", "page_range": "1-5" }, "job_id": "e3b0c442-...", "credits_used": 2.5 } ``` `classification` is returned at the top level for quick access and repeated inside `classify_output`, which keeps the routing decision (`classification`, `pipeline_id`, `page_range`) self-contained for pipeline consumers. Both always hold the same value. *** ## Routing to a Pipeline `/classify` only tells you *which* pipeline to use — it does not execute it. Take the returned `pipeline_id` and run that pipeline with the same document; its own extraction settings then apply. `classify` can also be used as the **first step of an ad-hoc pipeline**, where it classifies the raw document before the downstream steps run. It cannot be combined with `batch_extract` (classify needs a single document), and it is currently supported with inline config only. *** ## Credits `/classify` is billed at **0.5 credits per page** — half the `/extract` rate (1 credit/page). * **PDFs / images:** billed for the pages in the (defaulted/clamped) `page_range`. * **Office / HTML:** billed for every page, even when `page_range` is smaller. See the full rate table in [Credit Usage](/api-reference/introduction#credit-usage). *** ## Error Responses | Status | Error | Description | | ------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `REQ_004` | Missing `classify_config` or empty `classifications` | | 400 | `REQ_002` | `classify_config` is not valid JSON | | 400 | `REQ_006` | Invalid or out-of-range `page_range` | | 400 | `PIPELINE_003` | A classification's `pipeline_id` doesn't exist for your organization | | 400 | `FILE_*` | Unsupported file type, file too large, or bad/blocked URL (same as `/extract`) | | 401 | Unauthorized | Invalid or missing API key | | 429 | Rate limit exceeded | Too many requests | | 500 | `PROC_001` | Processing failed — including the case where Pulse returns a name that isn't one of your `classifications` keys ([add a catch-all](#always-include-a-catch-all-classification)) | *** ## Best Practices Classification is forced-choice, so a document that fits none of your classes is still assigned to one of them. A catch-all with no `pipeline_id` gives unexpected documents somewhere to land and gives you a `null` `pipeline_id` to branch on. See [Always include a catch-all classification](#always-include-a-catch-all-classification). The description tells Pulse what belongs in each class. Describe what the documents contain, not just their name, and make classes clearly distinguishable from each other. This matters most for the catch-all, which loses to better-described classes when its own description is vague. The first 5 pages are usually enough to identify a document type. An explicit `page_range` has no hard cap, but large ranges scale credits linearly. Classification is a probabilistic hint, not a guarantee, and the response carries no confidence score — a borderline guess and a certain match look identical. Validate downstream where correctness matters. Nothing from `/classify` is persisted — the routed pipeline's `/extract` produces the durable output. # Retrieve URL (Deprecated) Source: https://docs.runpulse.com/api-reference/endpoint/convert POST /convert **Deprecated**: This endpoint is deprecated and will be removed in a future version. **Temporary Storage**: /convert is purely for temporary file storage into Pulse's S3 bucket (auto-deletes in 24 hours), and returns a presigned URL for use in /extract. For more robust processing please use your own cloud storage. # Cancel Job Source: https://docs.runpulse.com/api-reference/endpoint/delete-job DELETE /job/{jobId} Attempts to cancel an asynchronous job that is currently pending or processing. Jobs that have already completed will remain unchanged. ## Overview Cancel an asynchronous job that is currently pending or processing. Jobs that have already completed, failed, or been canceled will remain unchanged. ## Response A successful cancellation returns a confirmation message: ```json theme={null} { "job_id": "abc123-def456-ghi789", "message": "Job cancelled successfully" } ``` ### Response Fields | Field | Type | Description | | --------- | ------ | ----------------------------------------- | | `job_id` | string | Identifier of the job that was cancelled. | | `message` | string | Human-readable confirmation message. | ## When to Cancel Jobs Cancel jobs when: * The extraction is no longer needed * You submitted a job with incorrect parameters * You want to stop processing to conserve page usage Cancellation is best-effort. If a job is already mid-processing, it may complete before the cancellation takes effect. Pages used before cancellation are still counted toward your usage. ## Example Usage ### Cancel a Job ```python Python SDK theme={null} from pulse import Pulse client = Pulse(api_key="YOUR_API_KEY") job_id = "abc123-def456-ghi789" try: response = client.jobs.cancel_job(job_id=job_id) print(f"Job {response.job_id} cancelled successfully") print(f"Message: {response.message}") except Exception as e: print(f"Failed to cancel job: {e}") ``` ```typescript TypeScript SDK theme={null} import { PulseClient } from 'pulse-ts-sdk'; const client = new PulseClient({ apiKey: 'YOUR_API_KEY' }); const jobId = "abc123-def456-ghi789"; try { const response = await client.jobs.cancelJob({ jobId }); console.log(`Job ${response.job_id} cancelled successfully`); console.log(`Message: ${response.message}`); } catch (error) { console.log(`Failed to cancel job: ${error}`); } ``` ```bash curl theme={null} # Cancel a pending or processing job curl -X DELETE https://api.runpulse.com/job/abc123-def456-ghi789 \ -H "x-api-key: YOUR_API_KEY" ``` ### Cancel with Error Handling ```python Python SDK theme={null} from pulse import Pulse from pulse.core.api_error import ApiError client = Pulse(api_key="YOUR_API_KEY") def cancel_job_safely(job_id: str): """Cancel a job with comprehensive error handling.""" try: response = client.jobs.cancel_job(job_id=job_id) print(f"✓ Job {job_id} cancelled successfully") return True except ApiError as e: if e.status_code == 404: print(f"✗ Job {job_id} not found") elif e.status_code == 403: print(f"✗ You don't have access to job {job_id}") elif e.status_code == 401: print(f"✗ Invalid or missing API key") else: print(f"✗ Failed to cancel job: {e}") return False except Exception as e: print(f"✗ Unexpected error: {e}") return False # Example usage cancel_job_safely("abc123-def456-ghi789") ``` ```typescript TypeScript SDK theme={null} import { PulseClient } from 'pulse-ts-sdk'; const client = new PulseClient({ apiKey: 'YOUR_API_KEY' }); async function cancelJobSafely(jobId: string): Promise { /** * Cancel a job with comprehensive error handling. */ try { const response = await client.jobs.cancelJob({ jobId }); console.log(`✓ Job ${jobId} cancelled successfully`); return true; } catch (error: any) { if (error.statusCode === 404) { console.log(`✗ Job ${jobId} not found`); } else if (error.statusCode === 403) { console.log(`✗ You don't have access to job ${jobId}`); } else if (error.statusCode === 401) { console.log(`✗ Invalid or missing API key`); } else { console.log(`✗ Failed to cancel job: ${error.message}`); } return false; } } // Example usage cancelJobSafely("abc123-def456-ghi789"); ``` ```bash curl theme={null} #!/bin/bash JOB_ID="abc123-def456-ghi789" API_KEY="YOUR_API_KEY" response=$(curl -s -w "\n%{http_code}" -X DELETE \ "https://api.runpulse.com/job/${JOB_ID}" \ -H "x-api-key: ${API_KEY}") # Split response body and status code body=$(echo "$response" | head -n -1) status_code=$(echo "$response" | tail -n 1) case "$status_code" in 200) echo "✓ Job ${JOB_ID} cancelled successfully" echo "$body" | jq . ;; 401) echo "✗ Invalid or missing API key" ;; 403) echo "✗ You don't have access to job ${JOB_ID}" ;; 404) echo "✗ Job ${JOB_ID} not found" ;; *) echo "✗ Failed to cancel job (status $status_code)" echo "$body" ;; esac ``` ### Cancel Multiple Jobs ```python Python SDK theme={null} from pulse import Pulse client = Pulse(api_key="YOUR_API_KEY") job_ids = [ "job-111-aaa", "job-222-bbb", "job-333-ccc" ] for job_id in job_ids: try: client.jobs.cancel_job(job_id=job_id) print(f"✓ Cancelled: {job_id}") except Exception as e: print(f"✗ Failed to cancel {job_id}: {e}") ``` ```typescript TypeScript SDK theme={null} import { PulseClient } from 'pulse-ts-sdk'; const client = new PulseClient({ apiKey: 'YOUR_API_KEY' }); const jobIds = [ "job-111-aaa", "job-222-bbb", "job-333-ccc" ]; for (const jobId of jobIds) { try { await client.jobs.cancelJob({ jobId }); console.log(`✓ Cancelled: ${jobId}`); } catch (error: any) { console.log(`✗ Failed to cancel ${jobId}: ${error.message}`); } } ``` ```bash curl theme={null} #!/bin/bash API_KEY="YOUR_API_KEY" JOB_IDS=("job-111-aaa" "job-222-bbb" "job-333-ccc") for job_id in "${JOB_IDS[@]}"; do status=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \ "https://api.runpulse.com/job/${job_id}" \ -H "x-api-key: ${API_KEY}") if [ "$status" = "200" ]; then echo "✓ Cancelled: $job_id" else echo "✗ Failed to cancel $job_id (status $status)" fi done ``` ## Error Responses | Status | Description | | ------ | ---------------------------------------------------- | | `401` | Invalid or missing API key. | | `403` | You don't have access to this job. | | `404` | Job not found. | | `500` | Job could not be cancelled or internal server error. | # Extract File Source: https://docs.runpulse.com/api-reference/endpoint/extract POST /extract The primary endpoint for the Pulse API. Parses uploaded documents or remote file URLs and returns rich markdown content with optional structured data extraction based on user-provided schemas and extraction options. Set `async: true` to return immediately with a job_id for polling via GET /job/{jobId}. Otherwise processes synchronously. To process many files at once, see [Batch Extract](api:POST/batch/extract) or the [Batch Processing guide](/batch). ## Overview **Pipeline Step 1** — Extract is where document processing begins: every downstream step consumes its `extraction_id`. After extraction, you can optionally [split](/api-reference/endpoint/split) the document into topics, apply [schema extraction](/api-reference/endpoint/schema) to get structured data, or use [tables](/api-reference/endpoint/tables) for span-aware table extraction. Handling mixed document types? [`/classify`](/api-reference/endpoint/classify) can run before Extract to route each raw document to the right pipeline — it only chooses *which* pipeline runs, so extraction still happens here. Extract content from documents. Returns markdown or HTML formatted content with optional structured data extraction. For large results (typically documents over 70 pages, spreadsheet extractions, or any response above 5 MB), the API returns a one-time download link at `https://api.runpulse.com/results/{job_id}` instead of inlining the payload. Fetching that URL returns the same complete extraction result JSON you would receive inline. See [Large Result Response](#large-result-response) below. For large documents or batch processing workflows, set `async: true` to process asynchronously and poll for results via [GET /job/jobId](/api-reference/endpoint/poll). To process many files at once, use [Batch Extract](/api-reference/endpoint/batch-overview#batch-extract). It accepts an S3 prefix, local directory, or list of URLs and runs `/extract` on each file in parallel. ### Async Mode Set `async: true` to return immediately with a job ID for polling: ```json theme={null} { "file_url": "https://example.com/document.pdf", "async": true } ``` **Async Response (200):** ```json theme={null} { "job_id": "abc123-def456", "status": "pending", "message": "Document processing started" } ``` Use `GET /job/{job_id}` to poll for completion. ## Request ### Document Source Provide the document using one of these methods: | Field | Type | Description | | ---------- | ------ | -------------------------------------------------------------- | | `file` | binary | Document file to upload directly (multipart/form-data). | | `file_url` | string | Public or pre-signed URL that Pulse will download and extract. | ### Extraction Options | Field | Type | Default | Description | | ------------------- | ------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `model` | string (enum) | `default` | Extraction model to use. One of `default` or `pulse-ultra-2`. `pulse-ultra-2` uses Pulse's vision-language model with built-in refinement, figure/chart extraction, and word-level bounding boxes. | | `pages` | string | - | Page range filter (1-indexed). Supports segments like `1-2` or mixed ranges like `1-2,5`. Page 1 is the first page. | | `figure_processing` | object | - | Settings that control how figures in the document are processed. These affect the **markdown output directly** and do not produce additional output fields. See [Figure Processing](#figure-processing). | | `extensions` | object | - | Settings that enable additional processing or alternate output formats. Each enabled extension produces a corresponding result under `response.extensions.*`. See [Extensions](#extensions). | | `spreadsheet` | object | - | Settings for Excel/spreadsheet extraction. Controls hidden rows, columns, sheets, raw values, phantom-cell trimming, and whether table `cell_data` is included. Applies to `.xlsx`, `.xlsm`, and `.xls` files. See [Spreadsheet Options](#spreadsheet-options). | | `storage` | object | - | Options for persisting extraction artifacts. See [Storage Options](#storage-options). | | `async` | boolean | `false` | If `true`, returns immediately with a `job_id` for polling via `GET /job/{jobId}`. | | `force_url` | boolean | `false` | When `true`, return the complete extraction result as a URL even if it is small. Spreadsheet responses are URL-backed by default; set `force_url: false` to request inline spreadsheet output. URL delivery changes only the transport, not the result shape. | | `structured_output` | object | - | **⚠️ Deprecated** — Use the [`/schema`](/api-reference/endpoint/schema) endpoint after extraction instead. Still works for backward compatibility. | ### Figure Processing Settings under `figure_processing` control how figures (images, charts, diagrams) and embedded visuals are processed. Applies to both PDFs/images (figures detected from layout) and spreadsheets (charts and embedded images read directly from the workbook). Affects the markdown output and the `bounding_boxes.Images[]` array. | Field | Type | Default | Description | | ------------------------------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `figure_processing.description` | boolean | `false` | Generate descriptive captions for extracted visuals. Captions appear under `bounding_boxes.Images[].description` and inline in the markdown output. Applies to both detected charts and non-chart images. | | `figure_processing.show_images` | boolean | `false` | Return image URLs for extracted visuals. URLs appear under `bounding_boxes.Images[].image_url` and resolve to a Pulse-hosted PNG/JPEG served from [`GET /results/{jobId}/images/{filename}`](/api-reference/endpoint/results-image). Applies to both detected charts and non-chart images. | For spreadsheets specifically, `show_images: true` collects every embedded chart and image in the workbook and emits one entry per visual under `bounding_boxes.Images`, with chart-specific fields like `chart_type`, `chart_title`, and `source_ranges` populated. See [Bounding Boxes](/api-reference/bounding-boxes#images-array) for the full field list. ### Spreadsheet Options Settings under `spreadsheet` control how Excel workbooks (`.xlsx`, `.xlsm`, `.xls`) are processed. By default, hidden rows, columns, and sheets are excluded from extraction output, cell values are rendered the way Excel displays them, and table cell metadata is included. Phantom-cell trimming is opt-in. Spreadsheet responses are returned as full-result URLs by default because workbook `cell_data` can make the payload large even for modest `.xlsx` files. | Field | Type | Default | Description | | ----------------------------------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `spreadsheet.include_hidden_rows` | boolean | `false` | Include rows that are hidden in the Excel workbook. | | `spreadsheet.include_hidden_cols` | boolean | `false` | Include columns that are hidden in the Excel workbook. | | `spreadsheet.include_hidden_sheets` | boolean | `false` | Include sheets that are hidden in the Excel workbook. | | `spreadsheet.use_raw_values` | boolean | `false` | Emit the underlying numeric value for number cells instead of the Excel display-formatted text — e.g. `1201.67` rather than `$1,202` when the cell uses a rounded currency format. Useful when downstream processing needs exact amounts (cent-level precision) rather than what the workbook shows visually. Percent-formatted cells and dates keep their display rendering. Does not apply to legacy `.xls` files. | | `spreadsheet.only_data_rows` | boolean | `false` | When `true`, trim trailing empty rows past the last cell carrying a value or formula. See [Phantom-cell trimming](#phantom-cell-trimming-only_data_rows--only_data_cols) below. | | `spreadsheet.only_data_cols` | boolean | `false` | When `true`, trim trailing empty columns past the last cell carrying a value or formula. Same rationale as `only_data_rows`. | | `spreadsheet.cell_data` | boolean | `true` | Include cell-level table metadata under `bounding_boxes.Tables[].cell_data`. Set to `false` to omit this metadata and reduce output size. | These settings accept both camelCase (`includeHiddenRows`, `onlyDataRows`, `cellData`) and snake\_case (`include_hidden_rows`, `only_data_rows`, `cell_data`) formats. #### Phantom-cell trimming (`only_data_rows` / `only_data_cols`) Excel files exported from claims systems, ERPs, and other automated pipelines routinely declare a "used range" that extends hundreds of thousands of rows past where the data actually ends. A typical case: a 57 MB workbook with only \~500 rows of real data, where the other \~1,000,000 rows are empty cells that exist only because they were once selected and styled. These phantom cells inflate file size by orders of magnitude and can exhaust parser memory on the extraction pipeline. Set `only_data_rows: true` and `only_data_cols: true` to have Pulse scan each sheet once before parsing, find the largest row and column containing a value or formula, and ignore everything beyond that extent. Surviving cells keep their **original A1 coordinates** (e.g., a value at `B7` in the source is still `B7` in the output), so any citation or bounding box that references a specific cell remains stable. The trim only kicks in on large sheets (≥5 MB of XML per sheet), so small, well-formed workbooks pay no overhead either way. Both flags default to `false`. ### Pulse Ultra 2 Options These options are available only when `model: pulse-ultra-2` is set. Passing any of them with the default model returns a 400 error listing the offending fields. | Field | Type | Default | Description | | --------------------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `refine` | boolean | `false` | Run a full-page OCR and formatting correction pass after extraction. Improves accuracy on dense layouts, numerical values, and table structure. Adds \~1–2s per page. Overridden by `refine_options` if both are provided. | | `refine_options` | object | - | Granular refinement targets. Takes precedence over the boolean `refine` flag. See below. | | `refine_options.tables` | boolean | `false` | Fix table cell values, structure, and headers against the source image. | | `refine_options.text` | boolean | `false` | Fix OCR errors, missing or extra content, and numerical accuracy (tables untouched). | | `refine_options.formatting` | boolean | `false` | Add strikethrough, italic, bold, super/subscript, and LaTeX formatting (tables untouched). | | `extract_figure` | boolean | `false` | Convert charts and data visualizations into HTML `` blocks, wrapped in `` tags. Useful for financial decks, dashboards, and scientific charts. | | `figure_description` | boolean | `false` | Generate a 1–2 paragraph natural-language description of each picture, wrapped in `` tags. Combines well with `extract_figure`. | | `detect_selections` | boolean | `true` | Detect selected and unselected marks with a specialized selection-mark model. Improves accuracy on forms, checkboxes, radio buttons, handwritten checkmarks, X marks, and similar controls. Enabled by default for `pulse-ultra-2`; set to `false` to skip this pass. | | `additional_prompt` | string | `""` | Extra context injected into the extraction prompt. Use to steer extraction toward a specific domain or attention focus. Max 4000 characters. | | `custom_image_prompt` | string | `""` | Extra context appended to the prompt used by `figure_description` and `extract_figure`. Tunes image and chart interpretation. Max 2000 characters. | | `custom_refine_prompt` | string | `""` | Extra context appended to the refinement prompt. Only applies when `refine: true` or `refine_options` is set. Max 2000 characters. | #### Selection mark detection Use `detect_selections: true` with `model: pulse-ultra-2` when a document contains forms, checkboxes, radio buttons, handwritten selection marks, or other marked-choice controls. Pulse runs a specialized detection pass for these marks so selected/unselected states are less likely to be missed or confused with nearby text, boxes, or handwriting. When available, the detected state is returned on the relevant bounding-box items as `selected`. #### Markdown output additions When `extract_figure` or `figure_description` is enabled, figures in `response.markdown` include additional tags: ```html theme={null}
...HTML table for the chart... ...1–2 paragraph description...
``` When `refine` (or `refine_options`) is set, markdown content is post-processed page-by-page; output is cleaner but typically grows \~1.5–3x in size for dense documents. No new tags are introduced. ### Extensions Settings under `extensions` enable additional processing passes or alternate output formats. Each enabled extension produces a **corresponding output field** under `response.extensions.*`. For example, enabling `extensions.chunking` produces `response.extensions.chunking`, and enabling `extensions.alt_outputs.return_html` produces `response.extensions.alt_outputs.html`. | Field | Type | Default | Description | | ------------------------------------ | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `extensions.document_metadata` | boolean | `false` | Extract native properties and deterministic structure from the original file. Results appear under `response.extensions.document_metadata`. | | `extensions.footnote_references` | boolean | `false` | Link footnote markers to their corresponding footnote text. | | `extensions.chunking` | object | - | Chunking configuration. See below. | | `extensions.chunking.chunk_types` | string\[] | - | List of chunking strategies: `semantic`, `header`, `page`, `recursive`. | | `extensions.chunking.chunk_size` | integer | - | Maximum characters per chunk. | | `extensions.alt_outputs` | object | - | Alternate output formats. See below. | | `extensions.alt_outputs.wlbb` | boolean | `false` | Enable word-level bounding boxes (PDF only). Results in `response.extensions.alt_outputs.wlbb`. | | `extensions.alt_outputs.return_html` | boolean | `false` | Include HTML representation. `response.markdown` is still present; HTML is at `response.extensions.alt_outputs.html`. | | `extensions.alt_outputs.return_xml` | boolean | `false` | Include XML representation (work in progress). | ### `pulse-ultra-2` Rate Limits Requests made with `model: pulse-ultra-2` are subject to dedicated rate limits, separate from standard extraction: | Limit | Value | | ---------- | -------------- | | Per minute | 5 extractions | | Per hour | 20 extractions | | File size | 50 MB | | Concurrent | 2 per API key | The concurrent limit is the one that most commonly applies in practice — long-running extractions held open while new requests arrive will trip it first. ### Storage Options Control whether extractions are saved to your extraction library: | Field | Type | Default | Description | | --------------------- | ------------- | ------- | ------------------------------------------------------------------------------------- | | `storage.enabled` | boolean | `true` | Whether to persist extraction artifacts. Set to `false` for temporary extractions. | | `storage.folder_name` | string | - | Target folder name to save the extraction to. Creates the folder if it doesn't exist. | | `storage.folder_id` | string (uuid) | - | Target folder ID to save the extraction to. Takes precedence over `folder_name`. | ### Deprecated Fields The following input fields are deprecated and will be removed in a future version. They are still accepted for backward compatibility. | Field | Replacement | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `show_images` | Use `figure_processing.show_images` | | `chunking` | Use `extensions.chunking.chunk_types` (array instead of comma-separated string) | | `chunk_size` | Use `extensions.chunking.chunk_size` | | `return_html` | Use `extensions.alt_outputs.return_html` | | `structured_output` | Use [`/schema`](/api-reference/endpoint/schema) endpoint after extraction. Pass `extraction_id` + `schema_config`. Accepts `schema`, `schema_prompt`, and `effort`. | | `schema` | Use [`/schema`](/api-reference/endpoint/schema) endpoint after extraction | | `schema_prompt` | Use [`/schema`](/api-reference/endpoint/schema) endpoint with `schema_config.schema_prompt` | | `custom_prompt` | No replacement | | `thinking` | No replacement | When legacy input fields are used, the API returns a deprecation warning in the `warnings` array directing you to the updated field names. See the [latest documentation](https://docs.runpulse.com/api-reference/endpoint/extract) for details. ## Response The response structure varies based on document size to optimize for different use cases. ### Standard Inline Response For non-spreadsheet documents under 70 pages whose response payload stays below the inline threshold, results are returned directly in the response body: ```json theme={null} { "markdown": "# Document Title\n\nExtracted content...", "page_count": 15, "extraction_id": "abc123-def456-ghi789", "extraction_url": "https://platform.runpulse.com/dashboard/extractions/abc123", "credits_used": 1.0, "plan_info": { "tier": "growth", "pages_used": 15, "total_credits_used": 49.5, "note": "Pulse Ultra" }, "bounding_boxes": { "Title": [], "Text": [], "Tables": [], "Images": [ { "id": "excel_image_1_1", "visual_type": "chart", "image_url": "https://api.runpulse.com/results/abc123-def456-ghi789/images/excel_image_1_1.png", "chart_type": "BarChart", "chart_title": "Revenue", "excel_range": "D2", "sheet_name": "Charts" } ], "markdown_with_ids": "

..." }, "extensions": { "chunking": { "semantic": ["chunk 1...", "chunk 2..."], "header": ["section 1...", "section 2..."] }, "altOutputs": { "html": "..." } }, "warnings": [] } ``` #### Response Fields | Field | Type | Description | | ------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `markdown` | string | Clean markdown content extracted from the document. Always present. | | `page_count` | integer | Total number of pages processed. | | `extraction_id` | string (uuid) | Persisted extraction ID. Present when storage is enabled (default). Use with `/split` and `/schema`. | | `extraction_url` | string | URL to view the extraction in the Pulse Platform. Present when storage is enabled. | | `credits_used` | number | Credits consumed by **this request**. Only present when the org has the credit billing system enabled. | | `plan_info` | object | Billing tier and **cumulative** usage information for the calling org, including this request. Includes `tier`, `total_credits_used` (primary billing metric), `pages_used` (legacy), and an optional `note`. | | `bounding_boxes` | object | Typed bounding-box data — `Images`, `Tables`, `Text`, `Title`, `Footer`, plus `markdown_with_ids`. See [Bounding Boxes](/api-reference/bounding-boxes) for the full field list including the chart/image fields under `Images`. | | `extensions` | object | Output from enabled extensions. Only keys for enabled extensions are present. See below. | | `extensions.document_metadata` | object | Native properties and deterministic structure from the original file (when `extensions.document_metadata` is enabled). See [Document Metadata](#document-metadata) below. | | `extensions.chunking` | object | Chunk results by strategy (when `extensions.chunking` is enabled). | | `extensions.footnoteReferences` | array | List of detected footnotes with their in-text references (when `extensions.footnote_references` is enabled). See [Footnote References](#footnote-references) below. | | `extensions.altOutputs.wlbb` | object | Word-level bounding boxes (when `extensions.alt_outputs.wlbb` is enabled). | | `extensions.altOutputs.html` | string | HTML representation (when `extensions.alt_outputs.return_html` is enabled). | | `extensions.altOutputs.xml` | string | XML representation (when `extensions.alt_outputs.return_xml` is enabled, WIP). | | `warnings` | array | Non-fatal warnings generated during extraction, including deprecation notices for legacy input usage. | #### Deprecated Response Fields | Field | Replacement | Description | | ------------------- | ----------------------------------------------- | ----------------------------------------------------------------- | | `html` | `extensions.altOutputs.html` | Present when legacy `return_html` input is used. | | `chunks` | `extensions.chunking` | Present when legacy `chunking` input is used. | | `plan-info` | `plan_info` | Present when only legacy inputs are used. | | `structured_output` | Use [`/schema`](/api-reference/endpoint/schema) | Present when deprecated `structured_output` input was used. | | `input_schema` | Use [`/schema`](/api-reference/endpoint/schema) | Echo of the applied schema (deprecated path only). | | `schema_error` | Use [`/schema`](/api-reference/endpoint/schema) | Error message if schema processing failed (deprecated path only). | ### Large Result Response For documents with 70 or more pages, spreadsheet extractions, or any response payload above the 5 MB inline threshold, the API returns a one-time download link to `/results/{job_id}` instead of inlining the payload. This prevents timeout issues and keeps the immediate response small. The downloaded JSON is the complete extraction result with the normal response shape. ```json theme={null} { "is_url": true, "url": "https://api.runpulse.com/results/abc123-def456-ghi789", "extraction_id": "abc123-def456-ghi789" } ``` #### Large Result Response Fields | Field | Type | Description | | --------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `is_url` | boolean | Always `true` for URL-backed responses. Use this to detect URL-based responses. | | `url` | string | One-time download link of the form `https://api.runpulse.com/results/{job_id}`. The link streams the complete extraction result the first time it is fetched and is then invalidated (subsequent reads return `410 Gone`). It also expires 1 hour after the job completes. Authenticate the request with your `x-api-key` header. | | `extraction_id` | string | Extraction/job identifier. The downloaded result contains the normal response fields, including metadata such as `page_count`, `credits_used`, and `plan_info` when available. | `/results/{job_id}` links are **single-use** and **expire 1 hour** after the job completes. Download and persist the payload immediately — do not pass the URL through queues or share it across workers. #### Handling Large Document Responses ```python Python theme={null} import requests from pulse import Pulse API_KEY = "YOUR_API_KEY" client = Pulse(api_key=API_KEY) response = client.extract( file_url="https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf" ) if hasattr(response, "is_url") and response.is_url: full_result = requests.get( response.url, headers={"x-api-key": API_KEY}, ).json() print(full_result["markdown"]) else: print(response.markdown) ``` ```typescript TypeScript theme={null} import { PulseClient } from 'pulse-ts-sdk'; const API_KEY = "YOUR_API_KEY"; const client = new PulseClient({ apiKey: API_KEY }); const response = await client.extract({ fileUrl: "https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf" }); if ((response as any).is_url) { const fullResult = await fetch((response as any).url, { headers: { "x-api-key": API_KEY }, }).then(r => r.json()); console.log(fullResult.markdown); } else { console.log(response.markdown); } ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/extract \ -H "x-api-key: YOUR_API_KEY" \ -F "file=@large_document.pdf" # Response: {"is_url": true, "url": "https://api.runpulse.com/results/abc123-..."} # Fetch the result once (single-use link, valid for 1 hour after job completion) curl -H "x-api-key: YOUR_API_KEY" \ "https://api.runpulse.com/results/abc123-..." ``` Because `/results/{job_id}` is one-time use, persist the result to your own storage on first download. If you need to access the result later, enable `storage.enabled` and retrieve it from your extraction library on the Pulse Platform. ## Example Usage ### Basic Extraction ```python Python theme={null} from pulse import Pulse from pulse.types import ( ExtractRequestFigureProcessing, ExtractRequestExtensions, ExtractRequestExtensionsAltOutputs, ) client = Pulse(api_key="YOUR_API_KEY") # Extract from URL with figure processing and HTML output response = client.extract( file_url="https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf", figure_processing=ExtractRequestFigureProcessing( description=True, ), extensions=ExtractRequestExtensions( alt_outputs=ExtractRequestExtensionsAltOutputs( return_html=True, ), ), ) print(f"Markdown: {response.markdown}") print(f"HTML: {response.extensions.alt_outputs.html}") print(f"Extraction ID: {response.extraction_id}") ``` ```typescript TypeScript theme={null} import { PulseClient } from 'pulse-ts-sdk'; const client = new PulseClient({ apiKey: "YOUR_API_KEY" }); const response = await client.extract({ fileUrl: "https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf", figureProcessing: { description: true }, extensions: { altOutputs: { returnHtml: true } } }); console.log(`Markdown: ${response.markdown}`); console.log(`HTML: ${response.extensions?.altOutputs?.html}`); console.log(`Extraction ID: ${response.extraction_id}`); ``` ```bash curl theme={null} # Extract from URL with figure processing curl -X POST https://api.runpulse.com/extract \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "file_url": "https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf", "figureProcessing": {"description": true}, "extensions": {"altOutputs": {"returnHtml": true}} }' ``` ### File Upload ```python Python theme={null} from pulse.types import ExtractRequestFigureProcessing # Upload and extract a local file with open("document.pdf", "rb") as f: response = client.extract( file=f, figure_processing=ExtractRequestFigureProcessing( description=True, ), ) ``` ```typescript TypeScript theme={null} import * as fs from 'fs'; const fileBuffer = fs.readFileSync("document.pdf"); const blob = new Blob([fileBuffer], { type: 'application/pdf' }); const response = await client.extract({ file: blob, }); ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/extract \ -H "x-api-key: YOUR_API_KEY" \ -F "file=@document.pdf" ``` ### Structured Data (Extract → Schema) The `structured_output` parameter on `/extract` is **deprecated**. Use the [`/schema`](/api-reference/endpoint/schema) endpoint after extraction instead. This gives you better control, re-runnability, and support for split-mode schemas. **Recommended two-step approach:** ```python Python theme={null} # Step 1: Extract the document response = client.extract( file_url="https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf" ) extraction_id = response.extraction_id # Step 2: Apply schema separately schema_result = client.schema( extraction_id=extraction_id, schema_config={ "input_schema": { "type": "object", "properties": { "total": {"type": "number"}, "vendor": {"type": "string"} } }, "schema_prompt": "Extract invoice total and vendor" } ) print(schema_result.schema_output) ``` ```typescript TypeScript theme={null} // Step 1: Extract the document const response = await client.extract({ fileUrl: "https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf" }); const extractionId = response.extraction_id; // Step 2: Apply schema separately const schemaResult = await client.schema({ extraction_id: extractionId, schema_config: { input_schema: { type: "object", properties: { total: { type: "number" }, vendor: { type: "string" } } }, schema_prompt: "Extract invoice total and vendor" } }); console.log(schemaResult.schema_output); ``` ```bash curl theme={null} # Step 1: Extract the document curl -X POST https://api.runpulse.com/extract \ -H "x-api-key: YOUR_API_KEY" \ -F "file=@invoice.pdf" # Response includes extraction_id: "abc123-..." # Step 2: Apply schema curl -X POST https://api.runpulse.com/schema \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "extraction_id": "abc123-...", "schema_config": { "input_schema": {"type": "object", "properties": {"total": {"type": "number"}, "vendor": {"type": "string"}}}, "schema_prompt": "Extract invoice total and vendor" } }' ``` ### Document Metadata Enable `extensions.document_metadata` to read native properties from the original file before conversion, rendering, or OCR. The option is a single boolean; Pulse returns every safely recoverable field for the detected format. ```python Python SDK theme={null} from pulse.types import ExtractRequestExtensions response = client.extract( file_url="https://example.com/report.pdf", extensions=ExtractRequestExtensions( document_metadata=True, ), ) metadata = response.extensions.document_metadata print(metadata.properties.title) print(metadata.structure.page_count) ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/extract \ -H "x-api-key: YOUR_API_KEY" \ -F "file=@report.pdf" \ -F 'extensions={"document_metadata":true};type=application/json' ``` ```json theme={null} { "extensions": { "document_metadata": { "file": { "name": "pulse-complex-metadata-10-page.pdf", "extension": ".pdf", "media_type": "application/pdf", "size_bytes": 32506 }, "properties": { "title": "Pulse Complex Metadata Validation Report", "authors": ["Ritvik Pandey", "Pulse Document Intelligence"], "created_at": "2026-01-15T09:30:00-08:00" }, "structure": { "page_count": 10, "outline_count": 10, "attachment_count": 1, "annotation_count": 5, "form_field_count": 3 }, "warnings": [] } } } ``` Absent metadata fields are omitted rather than returned as `null`. Metadata is evidence declared by the source file and is not independently verified. Original camera files may contain sensitive capture timestamps or GPS coordinates. See [Document Metadata](/concepts/processing-parameters-document-metadata) for format-specific behavior and implementation guidance. ### Page Range and Chunking ```python Python theme={null} from pulse.types import ( ExtractRequestExtensions, ExtractRequestExtensionsChunking, ) response = client.extract( file_url="https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf", pages="1-5,10", # 1-indexed extensions=ExtractRequestExtensions( chunking=ExtractRequestExtensionsChunking( chunk_types=["semantic", "page"], chunk_size=1000, ), ), ) # Chunk data is in extensions.chunking print(response.extensions.chunking.semantic) print(response.extensions.chunking.page) ``` ```typescript TypeScript theme={null} const response = await client.extract({ fileUrl: "https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf", pages: "1-5,10", // 1-indexed extensions: { chunking: { chunkTypes: ["semantic", "page"], chunkSize: 1000 } } }); // Chunk data is in extensions.chunking console.log(response.extensions?.chunking?.semantic); console.log(response.extensions?.chunking?.page); ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/extract \ -H "x-api-key: YOUR_API_KEY" \ -F "file=@document.pdf" \ -F "pages=1-5,10" \ -F 'extensions={"chunking": {"chunkTypes": ["semantic", "page"], "chunkSize": 1000}}' ``` ### Footnote References Enable `extensions.footnote_references` to detect footnote markers (e.g. `*`, `†`, `1`) in body text and link them to the footnote explanation paragraphs at the bottom of the page. Each result item includes the marker symbol, the bounding-box text ID of the footnote, and the bounding-box text IDs of all body-text paragraphs that reference it. ```python Python theme={null} from pulse.types import ExtractRequestExtensions response = client.extract( file_url="https://example.com/research-paper.pdf", extensions=ExtractRequestExtensions( footnote_references=True, ), ) # Footnote links are in extensions.footnote_references for ref in response.extensions.footnote_references: print(f"Marker: {ref.symbol}") print(f" Footnote: {ref.footnote_text_id}") print(f" Referenced by: {ref.reference_text_ids}") ``` ```typescript TypeScript theme={null} const response = await client.extract({ fileUrl: "https://example.com/research-paper.pdf", extensions: { footnoteReferences: true } }); // Footnote links are in extensions.footnoteReferences for (const ref of response.extensions?.footnoteReferences ?? []) { console.log(`Marker: ${ref.symbol}`); console.log(` Footnote: ${ref.footnoteTextId}`); console.log(` Referenced by: ${ref.referenceTextIds}`); } ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/extract \ -H "x-api-key: YOUR_API_KEY" \ -F "file=@research-paper.pdf" \ -F 'extensions={"footnoteReferences": true}' ``` #### Example Response ```json theme={null} { "markdown": "...", "bounding_boxes": { ... }, "extensions": { "footnoteReferences": [ { "symbol": "*", "footnoteTextId": "txt-11", "referenceTextIds": ["txt-4", "txt-5", "txt-6", "txt-7", "txt-8"] }, { "symbol": "†", "footnoteTextId": "txt-12", "referenceTextIds": ["txt-8"] }, { "symbol": "4", "footnoteTextId": "txt-48", "referenceTextIds": ["txt-45"] } ] } } ``` #### Footnote Reference Fields | Field | Type | Description | | ------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `symbol` | string | The footnote marker symbol as detected in the document (e.g. `*`, `†`, `‡`, `1`, `#`). | | `footnoteTextId` | string | The bounding-box text ID (e.g. `txt-11`) of the footnote explanation paragraph. Cross-reference with `bounding_boxes.Footer` to get the footnote's content and position. | | `referenceTextIds` | string\[] | Bounding-box text IDs of body-text paragraphs that contain a reference to this footnote. Cross-reference with `bounding_boxes.Text` to get each paragraph's content and position. | Footnote reference detection uses Azure Document Intelligence for paragraph classification, supplemented by PyMuPDF native text extraction for accurate symbol identification. This handles common OCR confusion between visually similar symbols like `†`/`+` and `‡`/`#`. Supported marker types include numbered (`1`, `2`, `3`), symbolic (`*`, `†`, `‡`, `§`, `#`), and lettered (`a`, `b`, `c`) footnotes. ### Excel Spreadsheet Options ```python Python theme={null} from pulse import Pulse from pulse.types import ExtractRequestSpreadsheet client = Pulse(api_key="YOUR_API_KEY") # Extract from Excel with hidden content included response = client.extract( file=open("financials.xlsx", "rb"), spreadsheet=ExtractRequestSpreadsheet( include_hidden_rows=True, include_hidden_cols=True, include_hidden_sheets=False, ), ) print(response.markdown) ``` ```typescript TypeScript theme={null} import { PulseClient } from 'pulse-ts-sdk'; const client = new PulseClient({ headers: { 'x-api-key': 'YOUR_API_KEY' } }); const response = await client.extract({ file: fs.createReadStream("financials.xlsx"), spreadsheet: { includeHiddenRows: true, includeHiddenCols: true, includeHiddenSheets: false } }); console.log(response.markdown); ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/extract \ -H "x-api-key: YOUR_API_KEY" \ -F "file=@financials.xlsx" \ -F 'spreadsheet={"includeHiddenRows": true, "includeHiddenCols": true, "includeHiddenSheets": false, "cellData": true}' ``` Spreadsheet responses are URL-backed by default: the immediate response is `is_url: true` with a one-time `/results/{job_id}` link. Fetch that URL to receive the complete extraction result. The result shape is unchanged: table metadata remains under `bounding_boxes.Tables[].cell_data` when `spreadsheet.cell_data` is `true` (the default). Set top-level `force_url: false` only if you need the full result inline. Workbooks exported from claims systems, ERPs, and other automated pipelines often declare a "used range" that extends hundreds of thousands of rows past where the data actually ends. Set `spreadsheet.only_data_rows: true` and `spreadsheet.only_data_cols: true` to have Pulse trim those trailing empty "phantom" rows and columns before parsing. Surviving cells keep their original A1 coordinates, so any citation or bounding box that references a specific cell remains stable. Both flags default to `false`. See the extraction options above for the full reference. ### Excel Charts and Embedded Images When you set `figure_processing.show_images: true` on an Excel workbook, every embedded chart and image is collected from the workbook directly and returned under `bounding_boxes.Images[]`. Each entry carries a Pulse-hosted `image_url` you can fetch via [`results.getImage`](/api-reference/endpoint/results-image) (or any HTTP client with your API key) to get the raw PNG/JPEG bytes. ```python Python theme={null} import re from pulse import Pulse from pulse.types import ExtractRequestFigureProcessing client = Pulse(api_key="YOUR_API_KEY") # 1) Extract the workbook with show_images enabled. response = client.extract( file=open("financials.xlsx", "rb"), figure_processing=ExtractRequestFigureProcessing( show_images=True, description=False, ), ) # 2) Walk the typed Images array. for img in response.bounding_boxes.images or []: print(f"{img.id}: {img.visual_type} '{img.chart_title}' @ {img.excel_range}") print(f" url: {img.image_url}") # 3) Fetch the bytes for one chart. img = response.bounding_boxes.images[0] m = re.search(r"/results/([^/]+)/images/([^/?#]+)", img.image_url) job_id, filename = m.group(1), m.group(2) chunks = list(client.results.get_image(job_id=job_id, filename=filename)) with open("chart.png", "wb") as f: f.write(b"".join(chunks)) ``` ```typescript TypeScript theme={null} import { PulseClient } from "pulse-ts-sdk"; import * as fs from "node:fs"; const client = new PulseClient({ apiKey: "YOUR_API_KEY" }); // 1) Extract the workbook with show_images enabled. const response = await client.extract({ file: fs.createReadStream("financials.xlsx"), figureProcessing: { showImages: true, description: false }, }); // 2) Walk the typed Images array. for (const img of response.boundingBoxes?.Images ?? []) { console.log( `${img.id}: ${img.visualType} '${img.chartTitle}' @ ${img.excelRange}`, ); console.log(` url: ${img.imageUrl}`); } // 3) Fetch the bytes for one chart. const url = response.boundingBoxes?.Images?.[0]?.imageUrl; const m = url?.match(/\/results\/([^/]+)\/images\/([^/?#]+)/); const [, jobId, filename] = m!; const image = await client.results.getImage({ jobId, filename }); // Persist `image` per your runtime (e.g. `await image.bytes()`). ``` ```bash curl theme={null} # Step 1: extract and capture an image_url from the response. curl -sS -X POST https://api.runpulse.com/extract \ -H "x-api-key: YOUR_API_KEY" \ -F "file=@financials.xlsx" \ -F 'figure_processing={"show_images": true}' \ | jq -r '.bounding_boxes.Images[0].image_url' # Step 2: fetch the PNG bytes. curl -sS -X GET "https://api.runpulse.com/results/$JOB_ID/images/excel_image_1_1.png" \ -H "x-api-key: YOUR_API_KEY" \ -o chart.png ``` #### Example `bounding_boxes.Images` Entry ```json theme={null} { "id": "excel_image_1_1", "visual_type": "chart", "page_number": 1, "bounding_box": [], "image_url": "https://api.runpulse.com/results/13e3e75f-.../images/excel_image_1_1.png", "sheet_name": "Charts", "excel_range": "D2", "chart_type": "BarChart", "chart_title": "Revenue", "source_ranges": ["'Charts'!$A$2:$A$5", "'Charts'!$B$2:$B$5"], "description": "Bar chart showing revenue by quarter." } ``` See [Bounding Boxes — Images Array](/api-reference/bounding-boxes#images-array) for the full field reference and [Get Result Image](/api-reference/endpoint/results-image) for the auth requirement on `image_url`. ### Disable Storage ```python Python theme={null} response = client.extract( file_url="https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf", storage={"enabled": False} ) ``` ```typescript TypeScript theme={null} const response = await client.extract({ fileUrl: "https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf", storage: { enabled: false } }); ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/extract \ -H "x-api-key: YOUR_API_KEY" \ -F "file=@document.pdf" \ -F 'storage={"enabled": false}' ``` # Extract File Async (Deprecated) Source: https://docs.runpulse.com/api-reference/endpoint/extract_async POST /extract_async **Deprecated**: Use `/extract` with `async: true` instead. Starts an asynchronous extraction job. The request mirrors the synchronous options but returns immediately with a job identifier that clients can poll for completion status. **Deprecated**: This endpoint is deprecated. Use [`/extract`](/api-reference/endpoint/extract) with `async: true` instead. ## Overview The asynchronous extraction endpoint accepts the same input parameters as the synchronous `/extract` endpoint but returns immediately with a job identifier. Use this endpoint for: * Large documents that may take longer to process * Batch processing workflows * Non-blocking integrations ### Migration Replace calls to `/extract_async` with `/extract` and add `async: true`: ```diff theme={null} - POST /extract_async - {"file_url": "https://example.com/doc.pdf"} + POST /extract + {"file_url": "https://example.com/doc.pdf", "async": true} ``` The response format is identical. ## Request ### Document Source Provide the document using one of these methods: | Field | Type | Description | | ---------- | ------ | -------------------------------------------------------------- | | `file` | binary | Document file to upload directly (multipart/form-data). | | `file_url` | string | Public or pre-signed URL that Pulse will download and extract. | ### Extraction Options | Field | Type | Default | Description | | ------------------- | ------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `model` | string (enum) | `default` | Extraction model to use. One of `default` or `pulse-ultra-2`. `pulse-ultra-2` uses Pulse's vision-language model with built-in refinement, figure/chart extraction, and word-level bounding boxes. | | `pages` | string | - | Page range filter (1-indexed). Supports segments like `1-2` or mixed ranges like `1-2,5`. Page 1 is the first page. | | `figure_processing` | object | - | Settings that control how figures in the document are processed. These affect the **markdown output directly** and do not produce additional output fields. See [Figure Processing](#figure-processing). | | `extensions` | object | - | Settings that enable additional processing or alternate output formats. Each enabled extension produces a corresponding result under `response.extensions.*`. See [Extensions](#extensions). | | `spreadsheet` | object | - | Settings for Excel/spreadsheet extraction. Controls hidden rows, columns, sheets, raw values, phantom-cell trimming, and whether table `cell_data` is included. Applies to `.xlsx`, `.xlsm`, and `.xls` files. See [Spreadsheet Options](#spreadsheet-options). | | `storage` | object | - | Options for persisting extraction artifacts. See [Storage Options](#storage-options). | | `async` | boolean | `false` | If `true`, returns immediately with a `job_id` for polling via `GET /job/{jobId}`. | | `force_url` | boolean | `false` | When `true`, return the complete extraction result as a URL even if it is small. Spreadsheet responses are URL-backed by default; set `force_url: false` to request inline spreadsheet output. URL delivery changes only the transport, not the result shape. | | `structured_output` | object | - | **⚠️ Deprecated** — Use the [`/schema`](/api-reference/endpoint/schema) endpoint after extraction instead. Still works for backward compatibility. | ### Figure Processing Settings under `figure_processing` control how figures (images, charts, diagrams) and embedded visuals are processed. Applies to both PDFs/images (figures detected from layout) and spreadsheets (charts and embedded images read directly from the workbook). Affects the markdown output and the `bounding_boxes.Images[]` array. | Field | Type | Default | Description | | ------------------------------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `figure_processing.description` | boolean | `false` | Generate descriptive captions for extracted visuals. Captions appear under `bounding_boxes.Images[].description` and inline in the markdown output. Applies to both detected charts and non-chart images. | | `figure_processing.show_images` | boolean | `false` | Return image URLs for extracted visuals. URLs appear under `bounding_boxes.Images[].image_url` and resolve to a Pulse-hosted PNG/JPEG served from [`GET /results/{jobId}/images/{filename}`](/api-reference/endpoint/results-image). Applies to both detected charts and non-chart images. | For spreadsheets specifically, `show_images: true` collects every embedded chart and image in the workbook and emits one entry per visual under `bounding_boxes.Images`, with chart-specific fields like `chart_type`, `chart_title`, and `source_ranges` populated. See [Bounding Boxes](/api-reference/bounding-boxes#images-array) for the full field list. ### Spreadsheet Options Settings under `spreadsheet` control how Excel workbooks (`.xlsx`, `.xlsm`, `.xls`) are processed. By default, hidden rows, columns, and sheets are excluded from extraction output, cell values are rendered the way Excel displays them, and table cell metadata is included. Phantom-cell trimming is opt-in. Spreadsheet responses are returned as full-result URLs by default because workbook `cell_data` can make the payload large even for modest `.xlsx` files. | Field | Type | Default | Description | | ----------------------------------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `spreadsheet.include_hidden_rows` | boolean | `false` | Include rows that are hidden in the Excel workbook. | | `spreadsheet.include_hidden_cols` | boolean | `false` | Include columns that are hidden in the Excel workbook. | | `spreadsheet.include_hidden_sheets` | boolean | `false` | Include sheets that are hidden in the Excel workbook. | | `spreadsheet.use_raw_values` | boolean | `false` | Emit the underlying numeric value for number cells instead of the Excel display-formatted text — e.g. `1201.67` rather than `$1,202` when the cell uses a rounded currency format. Useful when downstream processing needs exact amounts (cent-level precision) rather than what the workbook shows visually. Percent-formatted cells and dates keep their display rendering. Does not apply to legacy `.xls` files. | | `spreadsheet.only_data_rows` | boolean | `false` | When `true`, trim trailing empty rows past the last cell carrying a value or formula. See [Phantom-cell trimming](#phantom-cell-trimming-only_data_rows--only_data_cols) below. | | `spreadsheet.only_data_cols` | boolean | `false` | When `true`, trim trailing empty columns past the last cell carrying a value or formula. Same rationale as `only_data_rows`. | | `spreadsheet.cell_data` | boolean | `true` | Include cell-level table metadata under `bounding_boxes.Tables[].cell_data`. Set to `false` to omit this metadata and reduce output size. | These settings accept both camelCase (`includeHiddenRows`, `onlyDataRows`, `cellData`) and snake\_case (`include_hidden_rows`, `only_data_rows`, `cell_data`) formats. #### Phantom-cell trimming (`only_data_rows` / `only_data_cols`) Excel files exported from claims systems, ERPs, and other automated pipelines routinely declare a "used range" that extends hundreds of thousands of rows past where the data actually ends. A typical case: a 57 MB workbook with only \~500 rows of real data, where the other \~1,000,000 rows are empty cells that exist only because they were once selected and styled. These phantom cells inflate file size by orders of magnitude and can exhaust parser memory on the extraction pipeline. Set `only_data_rows: true` and `only_data_cols: true` to have Pulse scan each sheet once before parsing, find the largest row and column containing a value or formula, and ignore everything beyond that extent. Surviving cells keep their **original A1 coordinates** (e.g., a value at `B7` in the source is still `B7` in the output), so any citation or bounding box that references a specific cell remains stable. The trim only kicks in on large sheets (≥5 MB of XML per sheet), so small, well-formed workbooks pay no overhead either way. Both flags default to `false`. ### Pulse Ultra 2 Options These options are available only when `model: pulse-ultra-2` is set. Passing any of them with the default model returns a 400 error listing the offending fields. | Field | Type | Default | Description | | --------------------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `refine` | boolean | `false` | Run a full-page OCR and formatting correction pass after extraction. Improves accuracy on dense layouts, numerical values, and table structure. Adds \~1–2s per page. Overridden by `refine_options` if both are provided. | | `refine_options` | object | - | Granular refinement targets. Takes precedence over the boolean `refine` flag. See below. | | `refine_options.tables` | boolean | `false` | Fix table cell values, structure, and headers against the source image. | | `refine_options.text` | boolean | `false` | Fix OCR errors, missing or extra content, and numerical accuracy (tables untouched). | | `refine_options.formatting` | boolean | `false` | Add strikethrough, italic, bold, super/subscript, and LaTeX formatting (tables untouched). | | `extract_figure` | boolean | `false` | Convert charts and data visualizations into HTML `

` blocks, wrapped in `` tags. Useful for financial decks, dashboards, and scientific charts. | | `figure_description` | boolean | `false` | Generate a 1–2 paragraph natural-language description of each picture, wrapped in `` tags. Combines well with `extract_figure`. | | `detect_selections` | boolean | `true` | Detect selected and unselected marks with a specialized selection-mark model. Improves accuracy on forms, checkboxes, radio buttons, handwritten checkmarks, X marks, and similar controls. Enabled by default for `pulse-ultra-2`; set to `false` to skip this pass. | | `additional_prompt` | string | `""` | Extra context injected into the extraction prompt. Use to steer extraction toward a specific domain or attention focus. Max 4000 characters. | | `custom_image_prompt` | string | `""` | Extra context appended to the prompt used by `figure_description` and `extract_figure`. Tunes image and chart interpretation. Max 2000 characters. | | `custom_refine_prompt` | string | `""` | Extra context appended to the refinement prompt. Only applies when `refine: true` or `refine_options` is set. Max 2000 characters. | #### Selection mark detection Use `detect_selections: true` with `model: pulse-ultra-2` when a document contains forms, checkboxes, radio buttons, handwritten selection marks, or other marked-choice controls. Pulse runs a specialized detection pass for these marks so selected/unselected states are less likely to be missed or confused with nearby text, boxes, or handwriting. When available, the detected state is returned on the relevant bounding-box items as `selected`. #### Markdown output additions When `extract_figure` or `figure_description` is enabled, figures in `response.markdown` include additional tags: ```html theme={null}
...HTML table for the chart... ...1–2 paragraph description...
``` When `refine` (or `refine_options`) is set, markdown content is post-processed page-by-page; output is cleaner but typically grows \~1.5–3x in size for dense documents. No new tags are introduced. ### Extensions Settings under `extensions` enable additional processing passes or alternate output formats. Each enabled extension produces a **corresponding output field** under `response.extensions.*`. For example, enabling `extensions.chunking` produces `response.extensions.chunking`, and enabling `extensions.alt_outputs.return_html` produces `response.extensions.alt_outputs.html`. | Field | Type | Default | Description | | ------------------------------------ | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `extensions.document_metadata` | boolean | `false` | Extract native properties and deterministic structure from the original file. Results appear under `response.extensions.document_metadata`. | | `extensions.footnote_references` | boolean | `false` | Link footnote markers to their corresponding footnote text. | | `extensions.chunking` | object | - | Chunking configuration. See below. | | `extensions.chunking.chunk_types` | string\[] | - | List of chunking strategies: `semantic`, `header`, `page`, `recursive`. | | `extensions.chunking.chunk_size` | integer | - | Maximum characters per chunk. | | `extensions.alt_outputs` | object | - | Alternate output formats. See below. | | `extensions.alt_outputs.wlbb` | boolean | `false` | Enable word-level bounding boxes (PDF only). Results in `response.extensions.alt_outputs.wlbb`. | | `extensions.alt_outputs.return_html` | boolean | `false` | Include HTML representation. `response.markdown` is still present; HTML is at `response.extensions.alt_outputs.html`. | | `extensions.alt_outputs.return_xml` | boolean | `false` | Include XML representation (work in progress). | ### Storage Options Control whether extractions are saved to your extraction library: | Field | Type | Default | Description | | --------------------- | ------------- | ------- | ------------------------------------------------------------------------------------- | | `storage.enabled` | boolean | `true` | Whether to persist extraction artifacts. Set to `false` for temporary extractions. | | `storage.folder_name` | string | - | Target folder name to save the extraction to. Creates the folder if it doesn't exist. | | `storage.folder_id` | string (uuid) | - | Target folder ID to save the extraction to. Takes precedence over `folder_name`. | ### Deprecated Fields The following input fields are deprecated and will be removed in a future version. They are still accepted for backward compatibility. | Field | Replacement | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `show_images` | Use `figure_processing.show_images` | | `chunking` | Use `extensions.chunking.chunk_types` (array instead of comma-separated string) | | `chunk_size` | Use `extensions.chunking.chunk_size` | | `return_html` | Use `extensions.alt_outputs.return_html` | | `structured_output` | Use [`/schema`](/api-reference/endpoint/schema) endpoint after extraction. Pass `extraction_id` + `schema_config`. Accepts `schema`, `schema_prompt`, and `effort`. | | `schema` | Use [`/schema`](/api-reference/endpoint/schema) endpoint after extraction | | `schema_prompt` | Use [`/schema`](/api-reference/endpoint/schema) endpoint with `schema_config.schema_prompt` | | `custom_prompt` | No replacement | | `thinking` | No replacement | When legacy input fields are used, the API returns a deprecation warning in the `warnings` array directing you to the updated field names. See the [latest documentation](https://docs.runpulse.com/api-reference/endpoint/extract) for details. ## Response When you submit a document for async extraction, you'll receive a response containing the job metadata: ```json theme={null} { "job_id": "abc123-def456-ghi789", "status": "pending", "queuedAt": "2025-01-15T10:30:00Z" } ``` ### Response Fields | Field | Type | Description | | ---------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------- | | `job_id` | string | Unique identifier for the extraction job. Use this to poll for results with the [Poll Job](/api-reference/endpoint/poll) endpoint. | | `status` | string | Initial job status. Typically `pending` when first submitted. | | `queuedAt` | string | ISO 8601 timestamp indicating when the job was accepted. | ## Retrieving Results After submitting an async extraction, poll the job status endpoint to retrieve results: ```bash theme={null} GET /job/{job_id} ``` The job status endpoint will return the extraction results once the job is completed. See the [Poll Job](/api-reference/endpoint/poll) documentation for details on the response structure. For detailed information on the extraction output format (markdown, bounding boxes, chunks, etc.), see the [Extract](/api-reference/endpoint/extract) documentation. ## Example Usage ### Submit Async Extraction ```python Python theme={null} import time from pulse import Pulse client = Pulse(api_key="YOUR_API_KEY") # Submit async extraction submission = client.extract_async( file_url="https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf" ) print(f"Job ID: {submission.job_id}") print(f"Status: {submission.status}") # Poll for completion job_id = submission.job_id while True: job_status = client.jobs.get_job(job_id=job_id) print(f"Status: {job_status.status}") if job_status.status == "completed": print("Extraction complete!") print(f"Result: {job_status.result}") break elif job_status.status in ["failed", "canceled"]: print(f"Job ended: {job_status.status}") if job_status.error: print(f"Error: {job_status.error}") break time.sleep(2) ``` ```typescript TypeScript theme={null} import { PulseClient } from 'pulse-ts-sdk'; const client = new PulseClient({ apiKey: 'YOUR_API_KEY' }); // Submit async extraction const submission = await client.extract({ fileUrl: "https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf", async: true }); console.log(`Job ID: ${submission.job_id}`); console.log(`Status: ${submission.status}`); // Poll for completion const jobId = submission.job_id; while (true) { const jobStatus = await client.jobs.getJob({ jobId }); console.log(`Status: ${jobStatus.status}`); if (jobStatus.status === 'completed') { console.log('Extraction complete!'); console.log(`Result: ${JSON.stringify(jobStatus.result)}`); break; } else if (jobStatus.status === 'failed' || jobStatus.status === 'canceled') { console.log(`Job ended: ${jobStatus.status}`); if (jobStatus.error) { console.log(`Error: ${jobStatus.error}`); } break; } await new Promise(resolve => setTimeout(resolve, 2000)); } ``` ```bash curl theme={null} # Submit async extraction with file upload curl -X POST https://api.runpulse.com/extract_async \ -H "x-api-key: YOUR_API_KEY" \ -F "file=@document.pdf" # Submit async extraction with URL curl -X POST https://api.runpulse.com/extract_async \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"file_url": "https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf"}' # Response # {"job_id": "abc123", "status": "pending", "queuedAt": "2025-01-15T10:30:00Z"} # Poll for results curl https://api.runpulse.com/job/abc123 \ -H "x-api-key: YOUR_API_KEY" ``` ### With Structured Output ```python Python theme={null} schema = { "type": "object", "properties": { "total": {"type": "number"}, "vendor": {"type": "string"} } } submission = client.extract_async( file_url="https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf", structured_output={ "schema": schema, "schema_prompt": "Extract the invoice total" } ) ``` ```typescript TypeScript theme={null} const submission = await client.extract({ fileUrl: "https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf", async: true, structuredOutput: { schema: { type: "object", properties: { total: { type: "number" }, vendor: { type: "string" } } }, schemaPrompt: "Extract the invoice total" } }); ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/extract_async \ -H "x-api-key: YOUR_API_KEY" \ -F "file=@invoice.pdf" \ -F 'structured_output={"schema": {"type": "object", "properties": {"total": {"type": "number"}}}, "schema_prompt": "Extract the invoice total"}' ``` ### Cancel a Job ```python Python theme={null} # Cancel a running job cancellation = client.jobs.cancel_job(job_id=job_id) print(f"Cancelled: {cancellation.message}") # Verify cancellation status = client.jobs.get_job(job_id=job_id) print(f"Status: {status.status}") # Should be "canceled" ``` ```typescript TypeScript theme={null} // Cancel a running job const cancellation = await client.jobs.cancelJob({ jobId }); console.log(`Cancelled: ${cancellation.message}`); // Verify cancellation const status = await client.jobs.getJob({ jobId }); console.log(`Status: ${status.status}`); // Should be "canceled" ``` ```bash curl theme={null} # Cancel a job curl -X DELETE https://api.runpulse.com/job/abc123 \ -H "x-api-key: YOUR_API_KEY" ``` # Clear Form Source: https://docs.runpulse.com/api-reference/endpoint/form-clear POST /form/clear Remove user-entered data from a PDF form, leaving the blank form template intact. Erases handwritten entries, typed values, and unchecks selected checkboxes — printed labels, field titles, section headers, and other static template content are preserved. **Input modes** — provide exactly one of: - `form_id` — reuse a previously processed form from a prior `/form/detect`, `/form/fill`, or `/form/clear` call (fast path; cached layout reused). - `file_url` — public or pre-signed URL of a PDF Pulse will download. - `file` — direct binary upload of the PDF. All three input modes ride on the same `multipart/form-data` request body. (Callers sending `Content-Type: application/json` with `form_id` / `file_url` are still accepted server-side for backward compatibility, but the SDKs only model the multipart form.) `instructions` is optional. When omitted, Pulse clears every user-filled field deterministically (no LLM call) on AcroForm PDFs, eliminating any chance of hallucinated content. Provide a natural language prompt to clear only specific fields (e.g. `"clear only the address fields"`); targeted clears go through the LLM matcher with a delete-only filter. Optional `form_fields` and `page_range` (alias `pages`) behave the same as on [Form Fill](api:POST/form/fill). Synchronous by default — returns the cleared `FormResult` inline (including a `pdf_url` you can `GET` to download the PDF binary). Set `async: true` to receive `{job_id, status: "pending"}` and poll [GET /job/{jobId}](api:GET/job/{jobId}). Billed at **3 credits per page**. Requires the `form_filler` feature flag to be enabled for your organization. ## Overview Remove user-filled values from a PDF form while preserving the original printed template (labels, headers, instructions, structural text). Returns a `FormResult` synchronously by default. Set `async: true` to run in the background and poll [GET /job/jobId](/api-reference/endpoint/poll) for the result. `/form/clear` strips handwritten entries, typed responses, and selected checkbox marks from a PDF without altering the blank template underneath. `instructions` is **optional**: * **Omit** to clear every user-filled value on the form. * **Provide** a natural-language prompt (for example `"clear only the address fields"`) to scope the clear to specific fields. ### Providing the PDF Provide the PDF in **exactly one** of the following ways: * `form_id`: chain off a prior [`/form/detect`](/api-reference/endpoint/form-detect), [`/form/fill`](/api-reference/endpoint/form-fill), or `/form/clear` call. The cached PDF and `form_fields` are reused, so there is no need to re-upload. * `file_url`: public or presigned URL to a PDF. * `file`: PDF uploaded inline with the request. Sending more than one (or none) returns `400`. All three input modes ride on the same `multipart/form-data` request body — that's how the SDKs send every call. JSON bodies (`Content-Type: application/json`) with `form_id` or `file_url` are still accepted server-side for backward compatibility, but the SDKs only model the multipart form. ### Pricing Billed at **3 credits per page** of the PDF being cleared. Every response also returns a top-level `credits_used` for **this request** and a cumulative `plan_info.total_credits_used` snapshot for your organization. *** ## Request ### Request Body | Field | Type | Required | Description | | -------------- | ---------------------------------------------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------- | | `form_id` | string (uuid) | One of these | Reuse a previously processed form. Skips re-upload and re-detection. | | `file_url` | string (uri) | One of these | Public or presigned URL of a PDF to download and clear. | | `file` | binary | One of these | PDF uploaded inline with the request. | | `instructions` | string | No | Optional natural-language scoping prompt. When omitted, every user-filled value is cleared and the printed template is preserved. | | `form_fields` | array of [`FormCell`](/api-reference/endpoint/form-fill#response.body.form_fields) | No | Optional override for the cells used when clearing. | | `page_range` | string | No | 1-based page filter, for example `"1,3-5"`. Alias `pages` accepted. | | `async` | boolean | No | When `true`, returns `{ job_id, status: "pending" }` immediately (HTTP 202) and processes the job in the background. Default `false`. | *** ## Response ### Sync (200): `FormResult` Mirrors the [`/form/fill`](/api-reference/endpoint/form-fill#response) response, with `fields_cleared` substituted for `fields_filled`. | Field | Type | Description | | ---------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `form_id` | string (uuid) | ID of the new form record produced by this run. Pass back via `form_id` to chain. | | `page_count` | integer | Number of pages in the output PDF. | | `pdf_url` | string (uri) | URL to download the cleared PDF binary. Always points at [GET /results/jobId/pdf](/api-reference/endpoint/poll). Requires the same auth as the rest of the API. | | `form_fields` | array of `FormCell` | Detected cells of the resulting (cleared) PDF, refreshed after the clear. | | `fields_cleared` | integer | Number of cells whose value actually changed during this run. Fields that were already empty are **not** counted, so on an empty template this returns `0` even when many fields exist. | | `credits_used` | number | Credits consumed by this request (`3 × page_count`). | | `plan_info` | object | `{ tier, total_credits_used, pages_used }` cumulative billing snapshot for your organization (post-request). | ```json theme={null} { "form_id": "98056c28-569b-4689-a34a-396a68a66d4b", "page_count": 6, "pdf_url": "https://api.runpulse.com/results/933f730a-4b78-4b8a-bfd5-8aff3d6c880d/pdf", "form_fields": [ /* refreshed cells of the cleared PDF */ ], "fields_cleared": 23, "credits_used": 18.0, "plan_info": { "tier": "pulse_ultra_2", "total_credits_used": 1302.0, "pages_used": 434 } } ``` ### Async (202): `FormJobAccepted` When `async` is `true`: ```json theme={null} { "job_id": "abc123-def456-ghi789", "status": "pending" } ``` Poll [GET /job/jobId](/api-reference/endpoint/poll). The job's `result` carries the same `FormResult` shape that the sync flow would have returned inline. ### Status Codes | Code | Description | | ---- | --------------------------------------------------------------------------- | | 200 | Cleared `FormResult` returned synchronously. | | 202 | Async job accepted (`async: true`). Poll `/job/{jobId}` for the result. | | 400 | Missing PDF, more than one PDF source provided, or malformed `form_fields`. | | 401 | Authentication failed or missing API key. | | 404 | Referenced `form_id` not found (or belongs to a different org). | | 500 | Internal server error. | *** ## Example Usage ### Clear All User Input ```python Python theme={null} from pulse import Pulse client = Pulse(api_key="YOUR_API_KEY") result = client.form.clear( file_url="https://example.com/filled-form.pdf", ) print(f"form_id={result.form_id}") print(f"fields_cleared={result.fields_cleared}") print(f"download: {result.pdf_url}") ``` ```typescript TypeScript theme={null} import { PulseClient } from "pulse-ts-sdk"; const client = new PulseClient({ apiKey: "YOUR_API_KEY" }); const result = await client.form.clear({ file_url: "https://example.com/filled-form.pdf", }); console.log(`form_id=${result.form_id}`); console.log(`fields_cleared=${result.fields_cleared}`); console.log(`download: ${result.pdf_url}`); ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/form/clear \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "file_url": "https://example.com/filled-form.pdf" }' ``` ### Scoped Clear Pass `instructions` to clear only specific fields. ```python Python theme={null} result = client.form.clear( file_url="https://example.com/filled-form.pdf", instructions="Clear only the signature and date fields.", ) ``` ```typescript TypeScript theme={null} const result = await client.form.clear({ file_url: "https://example.com/filled-form.pdf", instructions: "Clear only the signature and date fields.", }); ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/form/clear \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "file_url": "https://example.com/filled-form.pdf", "instructions": "Clear only the signature and date fields." }' ``` ### Chain Fill, Clear, Fill Clear an existing filled form and immediately re-fill it with new values without re-uploading the PDF. The `form_id` returned by each step is the hand-off. ```python Python theme={null} filled = client.form.fill( file_url="https://example.com/intake-form.pdf", instructions="Fill in patient name as John Smith.", ) cleared = client.form.clear(form_id=filled.form_id) refilled = client.form.fill( form_id=cleared.form_id, instructions="Fill in patient name as Jane Doe.", ) ``` ```typescript TypeScript theme={null} const filled = await client.form.fill({ file_url: "https://example.com/intake-form.pdf", instructions: "Fill in patient name as John Smith.", }); const cleared = await client.form.clear({ form_id: filled.form_id! }); const refilled = await client.form.fill({ form_id: cleared.form_id!, instructions: "Fill in patient name as Jane Doe.", }); ``` ```bash curl theme={null} # Step 1: fill curl -X POST https://api.runpulse.com/form/fill \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"file_url":"https://example.com/intake-form.pdf","instructions":"Fill in patient name as John Smith."}' # Step 2: clear by form_id curl -X POST https://api.runpulse.com/form/clear \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"form_id": ""}' # Step 3: fill again by form_id curl -X POST https://api.runpulse.com/form/fill \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"form_id": "", "instructions": "Fill in patient name as Jane Doe."}' ``` ### Async Clear With Polling ```python Python theme={null} import time submission = client.form.clear( file_url="https://example.com/big-filled-form.pdf", async_=True, ) while True: job = client.jobs.get_job(job_id=submission.job_id) if job.status in ("completed", "failed"): break time.sleep(2) result = job.result print(f"fields_cleared={result['fields_cleared']} pdf_url={result['pdf_url']}") ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/form/clear \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "file_url": "https://example.com/big-filled-form.pdf", "async": true }' ``` # Detect Form Fields Source: https://docs.runpulse.com/api-reference/endpoint/form-detect POST /form/detect Run cell detection on a PDF and return the detected `form_fields` along with a reusable `form_id`. No LLM matching, no fill, no clear — this is the OCR / layout step that `/form/fill` and `/form/clear` would otherwise run internally. The returned `form_id` references the uploaded PDF and its detected layout, and can be passed back to a subsequent `/form/fill`, `/form/clear`, or `/form/detect` call as the single input source — Pulse will skip detection on the fast path and reuse the cached cells. **Input modes** — provide exactly one of: - `form_id` — re-detect cells on a previously stored PDF. Useful when callers want to refresh layout after editing or when chaining detect calls. - `file_url` — public or pre-signed URL Pulse will download. - `file` — direct binary upload of the PDF. All three input modes ride on the same `multipart/form-data` request body. (Callers sending `Content-Type: application/json` with `form_id` / `file_url` are still accepted server-side for backward compatibility, but the SDKs only model the multipart form.) Optional `page_range` (alias `pages`, e.g. `"1-3,5"`) restricts the operation to a subset of pages. Synchronous by default — returns the detected layout inline. Set `async: true` to receive `{job_id, status: "pending"}` immediately and poll [GET /job/{jobId}](api:GET/job/{jobId}). Billed at **1 credit per page**. Requires the `form_filler` feature flag to be enabled for your organization. ## Overview Detect form fields on a PDF and return them as structured cells along with a reusable `form_id`. Returns a `FormResult` synchronously by default. Set `async: true` to run in the background and poll [GET /job/jobId](/api-reference/endpoint/poll) for the result. `/form/detect` is the entry point for the form-filler workflow when you want to inspect the fields Pulse identified on a PDF before filling or clearing them. Use it to preview detected fields, fix a misclassified cell, see which checkboxes are currently selected, or cache the detection result for repeated chained calls. The returned `form_id` references the uploaded PDF and its detected layout, and can be passed back to any of `/form/detect`, `/form/fill`, or `/form/clear` as the single input source. Pulse will reuse the cached layout instead of re-detecting it. ### Providing the PDF Provide the PDF in **exactly one** of the following ways: * `form_id`: re-detect on a previously stored PDF (returned by an earlier `/form/detect`, `/form/fill`, or `/form/clear` call). Useful when chaining detect calls or refreshing layout after edits. * `file_url`: public or presigned URL to a PDF. * `file`: PDF uploaded inline with the request. Sending more than one (or none) returns `400`. All three input modes ride on the same `multipart/form-data` request body — that's how the SDKs send every call. JSON bodies (`Content-Type: application/json`) with `form_id` or `file_url` are still accepted server-side for backward compatibility, but the SDKs only model the multipart form. ### Pricing Billed at **1 credit per page** of the PDF being processed. Every response also returns a top-level `credits_used` for **this request** and a cumulative `plan_info.total_credits_used` snapshot for your organization. *** ## Request ### Request Body | Field | Type | Required | Description | | ------------ | ------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------- | | `form_id` | string (uuid) | One of these | Re-detect on a previously stored PDF. | | `file_url` | string (uri) | One of these | Public or presigned URL of a PDF. | | `file` | binary | One of these | PDF uploaded inline with the request. | | `page_range` | string | No | 1-based page filter, for example `"1,3-5"`. Alias `pages` accepted. | | `async` | boolean | No | When `true`, returns `{ job_id, status: "pending" }` immediately (HTTP 202) and processes the job in the background. Default `false`. | *** ## Response ### Sync (200): `FormResult` When `async` is `false` (default), the call returns a `FormResult` body directly. Since `/form/detect` does not modify the PDF, neither `fields_filled` nor `fields_cleared` is present. | Field | Type | Description | | -------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `form_id` | string (uuid) | ID of the form record produced by this run. Pass to a subsequent `/form/detect`, `/form/fill`, or `/form/clear` call. | | `page_count` | integer | Number of pages in the PDF. | | `pdf_url` | string (uri) | URL to download the (unmodified) PDF binary. Always points at [GET /results/jobId/pdf](/api-reference/endpoint/poll). Requires the same auth as the rest of the API. | | `form_fields` | array of [`FormCell`](/api-reference/endpoint/form-fill#response.body.form_fields) | Detected cells. Each carries a normalized `bounding_box`, a `type` (`text` / `checkbox` / `signature`), the current `text` content, and for checkbox cells a `checkbox_details[]` array with per-box center coordinates, selection state, and labels. | | `credits_used` | number | Credits consumed by this request (`1 × page_count`). | | `plan_info` | object | `{ tier, total_credits_used, pages_used }` cumulative billing snapshot for your organization (post-request). | ```json theme={null} { "form_id": "30fe08e1-922e-4012-9dfa-6aed0df430dc", "page_count": 6, "pdf_url": "https://api.runpulse.com/results/80690a27-ce39-4ad6-a1c7-70c7745238c3/pdf", "form_fields": [ { "page_number": 1, "type": "text", "bounding_box": [0.044, 0.038, 0.222, 0.052], "text": "Name (as shown on your income tax return)" }, { "page_number": 1, "type": "checkbox", "bounding_box": [0.118, 0.226, 0.634, 0.241], "text": "Individual/sole proprietor C corporation S corporation Partnership", "checkbox_details": [ { "center_coord": [0.125, 0.232], "selected": false, "text": "Individual/sole proprietor" }, { "center_coord": [0.300, 0.232], "selected": false, "text": "C corporation" }, { "center_coord": [0.418, 0.232], "selected": false, "text": "S corporation" }, { "center_coord": [0.535, 0.232], "selected": false, "text": "Partnership" } ] } ], "credits_used": 6.0, "plan_info": { "tier": "pulse_ultra_2", "total_credits_used": 1278.0, "pages_used": 426 } } ``` All cell coordinates (`bounding_box`, `checkbox_details[].center_coord`) are normalized to `[0, 1]` with a top-left origin. Multiply by your render width / height to convert to pixel coordinates. ### Async (202): `FormJobAccepted` When `async` is `true`: ```json theme={null} { "job_id": "abc123-def456-ghi789", "status": "pending" } ``` Poll [GET /job/jobId](/api-reference/endpoint/poll). The job's `result` carries the same `FormResult` shape that the sync flow would have returned inline. ### Status Codes | Code | Description | | ---- | ----------------------------------------------------------------------- | | 200 | Detected layout returned synchronously. | | 202 | Async job accepted (`async: true`). Poll `/job/{jobId}` for the result. | | 400 | Missing PDF or more than one PDF source provided. | | 401 | Authentication failed or missing API key. | | 404 | Referenced `form_id` not found (or belongs to a different org). | | 500 | Internal server error. | *** ## Example Usage ### Detect From URL ```python Python theme={null} from pulse import Pulse client = Pulse(api_key="YOUR_API_KEY") result = client.form.detect( file_url="https://www.irs.gov/pub/irs-pdf/fw9.pdf", ) print(f"form_id : {result.form_id}") print(f"page_count : {result.page_count}") print(f"# cells : {len(result.form_fields or [])}") print(f"credits : {result.credits_used} (1 x {result.page_count} pages)") for cell in (result.form_fields or [])[:3]: print(f" [{cell.type}] {cell.bounding_box} {cell.text!r}") ``` ```typescript TypeScript theme={null} import { PulseClient } from "pulse-ts-sdk"; const client = new PulseClient({ apiKey: "YOUR_API_KEY" }); const result = await client.form.detect({ file_url: "https://www.irs.gov/pub/irs-pdf/fw9.pdf", }); console.log(`form_id=${result.form_id}`); console.log(`page_count=${result.page_count}`); console.log(`# cells=${result.form_fields?.length ?? 0}`); ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/form/detect \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"file_url": "https://www.irs.gov/pub/irs-pdf/fw9.pdf"}' ``` ### File Upload ```python Python theme={null} with open("intake-form.pdf", "rb") as f: result = client.form.detect(file=f) ``` ```typescript TypeScript theme={null} import * as fs from "fs"; const fileBuffer = fs.readFileSync("intake-form.pdf"); const blob = new Blob([fileBuffer], { type: "application/pdf" }); const result = await client.form.detect({ file: blob }); ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/form/detect \ -H "x-api-key: YOUR_API_KEY" \ -F "file=@intake-form.pdf" ``` ### Detect, Edit, Then Fill Detect the cells once, hand-edit any that were misclassified, and pass the edited cells back to [`/form/fill`](/api-reference/endpoint/form-fill) along with the cached `form_id`. The fill call reuses the cached layout instead of re-detecting it. ```python Python theme={null} detect = client.form.detect(file_url="https://example.com/contract.pdf") # Re-tag a cell the detector got wrong edited = [] for cell in detect.form_fields or []: if cell.text and cell.text.strip().lower() == "signature": cell.type = "signature" edited.append(cell) fill = client.form.fill( form_id=detect.form_id, instructions="Sign as Jane Doe, dated 2026-05-01.", form_fields=edited, ) ``` ```typescript TypeScript theme={null} const detect = await client.form.detect({ file_url: "https://example.com/contract.pdf", }); const edited = (detect.form_fields ?? []).map((cell) => cell.text?.trim().toLowerCase() === "signature" ? { ...cell, type: "signature" as const } : cell, ); const fill = await client.form.fill({ form_id: detect.form_id!, instructions: "Sign as Jane Doe, dated 2026-05-01.", form_fields: edited, }); ``` ### Re-detect On A Stored Form Pass `form_id` (instead of `file_url` / `file`) to refresh the layout on a PDF already stored by Pulse. Useful after a `/form/clear` round-trip, or to grab the latest cells if you suspect drift. ```python Python theme={null} fresh = client.form.detect(form_id="00e2c454-4e6f-429b-bd74-320ad94b2153") ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/form/detect \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"form_id": "00e2c454-4e6f-429b-bd74-320ad94b2153"}' ``` # Fill Form Source: https://docs.runpulse.com/api-reference/endpoint/form-fill POST /form/fill Fill the fields of a PDF form with values inferred from a natural language `instructions` prompt. Works on both AcroForm PDFs (true form fields are written) and flat/scanned PDFs (values are rendered as an overlay using detected cells from OCR). **Input modes** — provide exactly one of: - `form_id` — reuse a previously processed form from a prior `/form/detect`, `/form/fill`, or `/form/clear` call. Skips re-detection (fast path); the cached `form_fields` are reused. - `file_url` — public or pre-signed URL of a PDF Pulse will download. - `file` — direct binary upload of the PDF. Pulse runs cell detection internally before filling. All three input modes ride on the same `multipart/form-data` request body. (Callers sending `Content-Type: application/json` with `form_id` / `file_url` are still accepted server-side for backward compatibility, but the SDKs only model the multipart form.) Optional `form_fields` lets callers supply or edit the detected cells before filling. Optional `page_range` (alias `pages`, e.g. `"1-3,5"`) restricts the operation to a subset of pages. Synchronous by default — returns the filled `FormResult` inline (including a `pdf_url` you can `GET` to download the PDF binary). Set `async: true` to receive `{job_id, status: "pending"}` and poll [GET /job/{jobId}](api:GET/job/{jobId}). Billed at **3 credits per page**. Requires the `form_filler` feature flag to be enabled for your organization. ## Overview Fill a PDF form from natural-language instructions. Returns a `FormResult` synchronously by default (with a `pdf_url` you can `GET` to download the filled PDF). Set `async: true` to run in the background and poll [GET /jobjobId](/api-reference/endpoint/poll) for the result. `/form/fill` writes values into the fields of a PDF form based on a natural-language `instructions` prompt. It works on both PDFs with native form fields (where the values are written directly into the form) and on flat or scanned PDFs (where the values are placed into the detected fields). ### Providing the PDF Provide the PDF in **exactly one** of the following ways: * `form_id`: chain off a prior [`/form/detect`](/api-reference/endpoint/form-detect), `/form/fill`, or [`/form/clear`](/api-reference/endpoint/form-clear) call. The cached PDF and `form_fields` are reused, so there is no need to re-upload. * `file_url`: public or presigned URL to a PDF. * `file`: PDF uploaded inline with the request. Sending more than one (or none) returns `400`. All three input modes ride on the same `multipart/form-data` request body — that's how the SDKs send every call. JSON bodies (`Content-Type: application/json`) with `form_id` or `file_url` are still accepted server-side for backward compatibility, but the SDKs only model the multipart form. ### Pricing Billed at **3 credits per page** of the PDF being filled. Every response also returns a top-level `credits_used` for **this request** and a cumulative `plan_info.total_credits_used` snapshot for your organization. *** ## Request ### Request Body | Field | Type | Required | Description | | -------------- | ------------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------- | | `form_id` | string (uuid) | One of these | Reuse a previously processed form. Skips re-upload and re-detection. | | `file_url` | string (uri) | One of these | Public or presigned URL of a PDF to download and fill. | | `file` | binary | One of these | PDF uploaded inline with the request. | | `instructions` | string | Yes | Natural-language description of what to fill into the form. Example: `"Use John Doe, 123 Main St, born 1990-01-01"`. | | `form_fields` | array of [`FormCell`](#response.body.form_fields) | No | Optional override for the cells used when filling. Useful when the caller has hand-edited the cells returned by `/form/detect`. | | `page_range` | string | No | 1-based page filter, for example `"1,3-5"`. Alias `pages` accepted. | | `async` | boolean | No | When `true`, returns `{ job_id, status: "pending" }` immediately (HTTP 202) and processes the job in the background. Default `false`. | *** ## Response ### Sync (200): `FormResult` When `async` is `false` (default), the call returns a `FormResult` body directly. | Field | Type | Description | | --------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `form_id` | string (uuid) | ID of the new form record produced by this run. Pass back via `form_id` to chain further fills, clears, or detects. | | `page_count` | integer | Number of pages in the output PDF. | | `pdf_url` | string (uri) | URL to download the filled PDF binary. Always points at [GET /results/jobId/pdf](/api-reference/endpoint/poll). Requires the same auth (API key or JWT) as the rest of the API and only serves results owned by the calling organization. | | `form_fields` | array of [`FormCell`](#response.body.form_fields) | Detected cells of the resulting (filled) PDF, refreshed after the fill. | | `fields_filled` | integer | Number of cells whose value actually changed during this run (no-op writes are not counted). | | `credits_used` | number | Credits consumed by this request (`3 × page_count`). | | `plan_info` | object | `{ tier, total_credits_used, pages_used }` cumulative billing snapshot for your organization (post-request). | ```json theme={null} { "form_id": "00e2c454-4e6f-429b-bd74-320ad94b2153", "page_count": 6, "pdf_url": "https://api.runpulse.com/results/dab7285d-8a65-4cb6-9d24-d5db64d3798e/pdf", "form_fields": [ { "page_number": 1, "type": "text", "bounding_box": [0.118, 0.226, 0.634, 0.241], "text": "Acme Logistics LLC" }, { "page_number": 1, "type": "checkbox", "bounding_box": [0.118, 0.226, 0.634, 0.241], "text": "Individual/sole proprietor C corporation S corporation Partnership", "checkbox_details": [ { "center_coord": [0.125, 0.232], "selected": true, "text": "Individual/sole proprietor" }, { "center_coord": [0.300, 0.232], "selected": false, "text": "C corporation" }, { "center_coord": [0.418, 0.232], "selected": false, "text": "S corporation" }, { "center_coord": [0.535, 0.232], "selected": false, "text": "Partnership" } ] } ], "fields_filled": 7, "credits_used": 18.0, "plan_info": { "tier": "pulse_ultra_2", "total_credits_used": 1284.0, "pages_used": 428 } } ``` All cell coordinates (`bounding_box`, `checkbox_details[].center_coord`) are normalized to `[0, 1]` with a top-left origin. Multiply by your render width / height to convert to pixel coordinates. ### Async (202): `FormJobAccepted` When `async` is `true`: ```json theme={null} { "job_id": "abc123-def456-ghi789", "status": "pending" } ``` Poll [GET /job/jobId](/api-reference/endpoint/poll). The job's `result` carries the same `FormResult` shape that the sync flow would have returned inline. ### Status Codes | Code | Description | | ---- | --------------------------------------------------------------------------------------------------- | | 200 | Filled `FormResult` returned synchronously. | | 202 | Async job accepted (`async: true`). Poll `/job/{jobId}` for the result. | | 400 | Missing PDF, more than one PDF source provided, missing `instructions`, or malformed `form_fields`. | | 401 | Authentication failed or missing API key. | | 404 | Referenced `form_id` not found (or belongs to a different org). | | 500 | Internal server error. | *** ## Example Usage ### Fill From URL ```python Python theme={null} from pulse import Pulse client = Pulse(api_key="YOUR_API_KEY") result = client.form.fill( file_url="https://example.com/intake-form.pdf", instructions="Fill in patient name as Jane Doe, DOB 01/15/1990.", ) print(f"form_id={result.form_id}") print(f"fields_filled={result.fields_filled}") print(f"credits_used={result.credits_used}") print(f"download: {result.pdf_url}") ``` ```typescript TypeScript theme={null} import { PulseClient } from "pulse-ts-sdk"; const client = new PulseClient({ apiKey: "YOUR_API_KEY" }); const result = await client.form.fill({ file_url: "https://example.com/intake-form.pdf", instructions: "Fill in patient name as Jane Doe, DOB 01/15/1990.", }); console.log(`form_id=${result.form_id}`); console.log(`fields_filled=${result.fields_filled}`); console.log(`download: ${result.pdf_url}`); ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/form/fill \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "file_url": "https://example.com/intake-form.pdf", "instructions": "Fill in patient name as Jane Doe, DOB 01/15/1990." }' ``` ### File Upload ```python Python theme={null} with open("intake-form.pdf", "rb") as f: result = client.form.fill( file=f, instructions="Fill in patient name as Jane Doe, DOB 01/15/1990.", ) ``` ```typescript TypeScript theme={null} import * as fs from "fs"; const fileBuffer = fs.readFileSync("intake-form.pdf"); const blob = new Blob([fileBuffer], { type: "application/pdf" }); const result = await client.form.fill({ file: blob, instructions: "Fill in patient name as Jane Doe, DOB 01/15/1990.", }); ``` ```bash curl theme={null} curl -X POST https://api.runpulse.com/form/fill \ -H "x-api-key: YOUR_API_KEY" \ -F "file=@intake-form.pdf" \ -F "instructions=Fill in patient name as Jane Doe, DOB 01/15/1990." ``` ### Detect First, Then Fill Run [`/form/detect`](/api-reference/endpoint/form-detect) to inspect the detected cells, optionally edit them, then chain a fill that reuses the same `form_id`. There is no need to re-upload the PDF. ```python Python theme={null} detect = client.form.detect(file_url="https://example.com/intake-form.pdf") # (Optional) edit detected cells locally, e.g. retype a misclassified cell edited = [] for cell in detect.form_fields or []: if cell.text and cell.text.strip().lower() == "signature": cell.type = "signature" edited.append(cell) result = client.form.fill( form_id=detect.form_id, instructions="Fill in patient name as Jane Doe, DOB 01/15/1990.", form_fields=edited, # omit to use the cached cells from detect ) ``` ```typescript TypeScript theme={null} const detect = await client.form.detect({ file_url: "https://example.com/intake-form.pdf", }); const edited = (detect.form_fields ?? []).map((cell) => cell.text?.trim().toLowerCase() === "signature" ? { ...cell, type: "signature" as const } : cell, ); const result = await client.form.fill({ form_id: detect.form_id!, instructions: "Fill in patient name as Jane Doe, DOB 01/15/1990.", form_fields: edited, }); ``` ```bash curl theme={null} # Step 1: detect curl -X POST https://api.runpulse.com/form/detect \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"file_url": "https://example.com/intake-form.pdf"}' # Step 2: fill via the form_id from step 1 curl -X POST https://api.runpulse.com/form/fill \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "form_id": "", "instructions": "Fill in patient name as Jane Doe, DOB 01/15/1990." }' ``` ### Async Fill With Polling Use `async: true` for long-running jobs (large PDFs, multi-page fills) so the client does not have to keep a connection open. ```python Python theme={null} import time submission = client.form.fill( file_url="https://example.com/big-form.pdf", instructions="Fill the form for Jane Doe ...", async_=True, # SDK aliases the reserved keyword ) while True: job = client.jobs.get_job(job_id=submission.job_id) if job.status in ("completed", "failed"): break time.sleep(2) result = job.result # same FormResult body as the sync flow print(f"fields_filled={result['fields_filled']} pdf_url={result['pdf_url']}") ``` ```typescript TypeScript theme={null} const submission = await client.form.fill({ file_url: "https://example.com/big-form.pdf", instructions: "Fill the form for Jane Doe ...", async: true, }); let job = await client.jobs.getJob({ jobId: submission.job_id! }); while (job.status !== "completed" && job.status !== "failed") { await new Promise((r) => setTimeout(r, 2000)); job = await client.jobs.getJob({ jobId: submission.job_id! }); } const result = job.result as Record; console.log(`fields_filled=${result.fields_filled} pdf_url=${result.pdf_url}`); ``` ```bash curl theme={null} # Submit async curl -X POST https://api.runpulse.com/form/fill \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "file_url": "https://example.com/big-form.pdf", "instructions": "Fill the form for Jane Doe ...", "async": true }' # Poll curl https://api.runpulse.com/job/ \ -H "x-api-key: YOUR_API_KEY" ``` ### Download The Filled PDF The `pdf_url` returned in `FormResult` points at `GET /results/{job_id}/pdf` and requires authentication (API key or JWT). ```python Python theme={null} job_id = result.pdf_url.rstrip("/").split("/")[-2] with open("filled.pdf", "wb") as out: for chunk in client.results.get_pdf(job_id=job_id): out.write(chunk) ``` ```typescript TypeScript theme={null} const jobId = result.pdf_url!.replace(/\/$/, "").split("/").slice(-2, -1)[0]; const pdfStream = await client.results.getPdf({ jobId }); // pdfStream is a ReadableStream; write to disk however you prefer. ``` ```bash curl theme={null} curl https://api.runpulse.com/results//pdf \ -H "x-api-key: YOUR_API_KEY" \ --output filled.pdf ``` # Download Large Result Source: https://docs.runpulse.com/api-reference/endpoint/large-results GET /results/{jobId} Download the full result for an extraction when `/extract` or `GET /job/{jobId}` returns `is_url: true`. The URL is single-use for anonymous proxy access: after a successful download, subsequent anonymous requests return 410 Gone. Same-org authenticated callers may replay while the artifact is retained. Legacy `GET /large_results/{jobId}` links remain supported as an alias. ## Overview Large results may be returned as a URL instead of an inline response body. Use this endpoint to download the full completed result for a job when an extract or job response includes `is_url: true`. Large-result links are intended for completed jobs. Poll `GET /job/{jobId}` first, then download the result URL if the job response says the result is URL-backed. ## Typical Flow ```mermaid theme={null} flowchart LR A["POST /extract async=true"] --> B["job_id"] B --> C["GET /job/{jobId}"] C --> D{Large result?} D -->|Yes| E["GET /results/{jobId}"] D -->|No| F[Use inline result] ``` ## Related Plan long-running and large-output workflows. Check async job status. # Pipeline Overview Source: https://docs.runpulse.com/api-reference/endpoint/pipeline-overview Chain classify, extract, schema, tables, charts, and split steps into document processing pipelines. # Document Processing Pipelines A pipeline is a sequence of API calls that process a document from raw file to structured data. You define each step in the Pulse Playground, test it interactively, then deploy it at scale using the generated SDK code. ## Supported Pipelines The most common valid pipeline configurations are: | Pipeline | Steps | Use Case | | ---------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | **Classify → Pipeline** | `/classify` → matched pipeline | Route each document to the right pipeline by type before extracting | | **Extract** | `/extract` | Basic content extraction — markdown, tables, figures | | **Extract → Schema** | `/extract` → `/schema` | Extract + apply a schema to get structured data | | **Extract → Split** | `/extract` → `/split` | Extract + split document into topic-based page groups | | **Extract → Split → Schema** | `/extract` → `/split` → `/schema` | Full pipeline — extract, split by topic, apply per-topic schemas | | **Extract → Tables** | `/extract` → `/tables` | Extract + structured table extraction with span detection and cross-page merging | | **Extract → Charts** | `/extract` → `/charts` | Extract + chart reconstruction with source boxes, series data, and exports | | **Batch Pipeline** | `/batch/extract` → `/batch/schema`, `/batch/tables`, `/batch/split` | Process many files through any pipeline in parallel. See [Batch Processing](/api-reference/endpoint/batch-overview). | ```mermaid theme={null} flowchart LR A[Document] -.->|Unknown type?| Z["/classify"] Z -.->|pipeline_id| B A --> B["/extract"] B --> C{Need structure?} C -->|Single schema| D["/schema"] C -->|Multi-section| E["/split"] E --> F["/schema (split mode)"] C -->|Tables| H["/tables"] C -->|Charts| I["/charts"] C -->|Content only| G[Done] ``` *** ## How It Works ### Step 0 (optional): Classify When you process mixed document types (invoices, bank statements, contracts, …), each type usually needs different extraction settings. Call [`/classify`](/api-reference/endpoint/classify) with the **raw document** and a set of candidate classifications — each optionally carrying a `pipeline_id` — and it returns which classification matched, so you can route the document to the right pipeline before extracting. By default, it evaluates the first five pages and costs half the `/extract` rate. ```python theme={null} import json, requests resp = requests.post( "https://api.runpulse.com/classify", headers={"x-api-key": "YOUR_API_KEY"}, files={"file": open("document.pdf", "rb")}, data={"classify_config": json.dumps({ "classifications": { "invoice": {"description": "Invoices or bills requesting payment.", "pipeline_id": "e5f6a7b8-..."}, "bank_statement": {"description": "Bank or account statements.", "pipeline_id": "b1a2c3d4-..."}, # Catch-all with no pipeline_id: classification is forced-choice, so # this is what keeps unrecognized documents out of the wrong pipeline. "unrecognized": {"description": "Any document that does not clearly match one of the other classifications."} } })}, ) pipeline_id = resp.json()["classify_output"]["pipeline_id"] if pipeline_id is None: ... # unrecognized — send to manual review instead of extracting ``` Classify always returns one of the classifications you supplied — it never reports "no match" on its own. Always include a catch-all class and leave off its `pipeline_id`, then branch on the `null`. See [Always include a catch-all classification](/api-reference/endpoint/classify#always-include-a-catch-all-classification). If all your documents are the same type, skip this step and start at extract. ### Step 1: Extract Every pipeline's processing starts with [`/extract`](/api-reference/endpoint/extract) (classify, if used, only decides *which* pipeline runs). This processes your document and returns markdown content, bounding boxes, and optional figures. ```python theme={null} result = client.extract( file=open("document.pdf", "rb") ) extraction_id = result.extraction_id ``` Storage is enabled by default. The `extraction_id` returned in the response is used to reference the saved extraction in subsequent pipeline steps. If you explicitly disable storage (`storage.enabled: false`), the extraction won't be available for split or schema steps. ### Step 2 (Option A): Schema Extraction For documents where you need structured data from the entire document, call [`/schema`](/api-reference/endpoint/schema) with the `extraction_id`: ```python theme={null} schema_result = client.schema( extraction_id=extraction_id, schema_config={ "input_schema": { "type": "object", "properties": { "invoice_number": {"type": "string"}, "total_amount": {"type": "number"} } }, "schema_prompt": "Extract invoice details" } ) ``` ### Step 2 (Option B): Split Document For multi-section documents (annual reports, contracts, medical records), call [`/split`](/api-reference/endpoint/split) to identify which pages contain each topic: ```python theme={null} split_result = client.split( extraction_id=extraction_id, split_config={ "split_input": [ {"name": "financials", "description": "Balance sheets and income statements"}, {"name": "risk_factors", "description": "Risk disclosures and legal disclaimers"} ] } ) split_id = split_result.split_id ``` ### Step 3: Schema on Split Results After splitting, call [`/schema`](/api-reference/endpoint/schema) with the `split_id` to apply different schemas to each topic's pages: ```python theme={null} schema_result = client.schema( split_id=split_id, split_schema_config={ "financials": { "schema": {"type": "object", "properties": {"revenue": {"type": "number"}}}, "schema_prompt": "Extract financial metrics" }, "risk_factors": { "schema": {"type": "object", "properties": {"risk": {"type": "string"}}} } } ) ``` ## Saved Configurations Each step's configuration can be saved to a **config library** for reuse: * **Extraction configs** — page ranges, figure settings, chunking options * **Split configs** — topic definitions with names and descriptions * **Schema configs** — JSON schemas with prompts and effort settings When a step uses a saved config, you reference it by ID instead of passing the full configuration inline: ```python theme={null} # Using saved config IDs result = client.extract( file=open("document.pdf", "rb"), extraction_config_id="abc-123" ) schema_result = client.schema( extraction_id=result.extraction_id, schema_config_id="def-456" ) ``` This makes your pipeline code cleaner and ensures consistency when processing many documents with the same configuration. *** ## From Playground to Production The Pulse Platform lets you build and test pipelines interactively: 1. **Configure** each step using the visual pipeline builder 2. **Run** the pipeline on a test document to verify results 3. **Save** the pipeline — each step's config is saved to your library 4. **Export** — click the **Show Code** button in the top-right corner of the extraction results panel The **Show Code** feature generates ready-to-use SDK code (Python, TypeScript, or cURL) that replicates your exact pipeline configuration. If your steps use saved presets, the generated code references their config IDs directly — no need to copy-paste JSON schemas. ### Deploying at Scale Once you have the generated code, you can deploy it in production to process documents at scale: ```python theme={null} from pulse import Pulse import os client = Pulse(api_key=os.environ["PULSE_API_KEY"]) documents = ["invoice_001.pdf", "invoice_002.pdf", "invoice_003.pdf"] for doc_path in documents: # Extract result = client.extract( file=open(doc_path, "rb"), extraction_config_id="your-extraction-config-id" ) # Apply schema using saved config schema_result = client.schema( extraction_id=result.extraction_id, schema_config_id="your-schema-config-id" ) print(f"{doc_path}: {schema_result.schema_output['values']}") ``` For high-throughput processing, use `async: true` on each step and poll for results: ```python theme={null} # Start async extraction job = client.extract( file=open(doc_path, "rb"), extraction_config_id="your-extraction-config-id", async_=True # Returns immediately with job_id ) # Poll for completion result = client.jobs.get_job(job_id=job.job_id) # Repeat until status is "completed" ``` See [Polling for Results](/api-reference/endpoint/poll) for details on async processing. *** ## Pipeline Steps Reference Step 0 (optional) — Route a raw document to the right pipeline by type Step 1 — Parse documents into markdown, tables, and figures Step 2 — Split document into topic-based page groups Step 2/3 — Apply schemas to extract structured data Step 2 (terminal) — Extract structured tables with span detection and cross-page merging Run any pipeline step across many documents in parallel # Poll Job Source: https://docs.runpulse.com/api-reference/endpoint/poll GET /job/{jobId} Check the status and retrieve results of an asynchronous job (submitted via any endpoint with `async: true`). ## Overview Check the status and retrieve results of an asynchronous job (e.g., submitted via `/extract` with `async: true`). Poll this endpoint periodically until the job reaches a terminal state (`completed`, `failed`, `canceled`, or `expired`). ## Response The response includes job metadata and, when completed, the full extraction results. ```json theme={null} { "job_id": "abc123-def456-ghi789", "status": "completed", "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T10:31:45Z", "result": { "markdown": "# Document Title\n\nExtracted content...", "page_count": 15, "bounding_boxes": { ... }, "plan-info": { ... } } } ``` ### Response Fields | Field | Type | Description | | ------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `job_id` | string | Unique identifier for the extraction job. | | `status` | string | Current job status: `pending`, `processing`, `completed`, `failed`, `canceled`, or `expired`. | | `created_at` | string | ISO 8601 timestamp when the job was submitted. | | `updated_at` | string | ISO 8601 timestamp of the last status update. | | `result` | object | Job output. Present when `status` is `completed`, and on `expired` as a retention stub. Large outputs return a `{ is_url, url }` pointer instead of inline data (see [Large Results](#large-results-is_url)). See [Extract](/api-reference/endpoint/extract) for result structure. | | `error` | string | Error message (only present when `status` is `failed`). | ### Job Status Values | Status | Description | | ------------ | ---------------------------------------------------------------------------------------------------------- | | `pending` | Job is queued and waiting to be processed. | | `processing` | Job is currently being processed. | | `completed` | Job finished successfully. Results are available in the `result` field. | | `failed` | Job encountered an error. See `error` field for details. | | `canceled` | Job was canceled before completion. | | `expired` | Job's retention window has passed and its stored output was purged. Run a new extraction to regenerate it. | ### Large Results (`is_url`) When the output is large (at or above 5 MB), or when a spreadsheet extraction uses default URL delivery, `result` is replaced with a pointer instead of inline data. Fetch `url` to download the full result JSON; the downloaded payload has the normal result shape. ```json theme={null} { "result": { "is_url": true, "url": "https://api.runpulse.com/results/abc123-def456-ghi789" } } ``` | Field | Type | Description | | -------- | ------- | ----------------------------------------------------------------------- | | `is_url` | boolean | `true` when `result` is a pointer rather than inline data. | | `url` | string | Location of the full result JSON (presigned S3 URL or Pulse proxy URL). | ## Polling Strategy We recommend polling with exponential backoff: ```python Python SDK theme={null} from pulse import Pulse import time client = Pulse(api_key="YOUR_API_KEY") def poll_job(job_id: str, max_attempts: int = 60): """ Poll for job completion with exponential backoff. Args: job_id: The job ID returned from /extract with async: true max_attempts: Maximum number of polling attempts Returns: The extraction result when job completes """ delay = 1 # Start with 1 second for attempt in range(max_attempts): # Get job status using the SDK response = client.jobs.get_job(job_id=job_id) if response.status == "completed": return response.result elif response.status == "failed": raise Exception(f"Job failed: {response.error}") elif response.status == "canceled": raise Exception("Job was canceled") # Still pending or processing - wait and retry print(f"Status: {response.status}, waiting {delay}s...") time.sleep(delay) delay = min(delay * 1.5, 10) # Cap at 10 seconds raise Exception("Polling timeout") # Example usage job_id = "abc123-def456-ghi789" result = poll_job(job_id) print(f"Extraction complete! Markdown: {result['markdown'][:100]}...") ``` ```typescript TypeScript SDK theme={null} import { PulseClient } from 'pulse-ts-sdk'; const client = new PulseClient({ apiKey: 'YOUR_API_KEY' }); async function pollJob(jobId: string, maxAttempts: number = 60): Promise { /** * Poll for job completion with exponential backoff. */ let delay = 1; // Start with 1 second for (let attempt = 0; attempt < maxAttempts; attempt++) { // Get job status using the SDK const response = await client.jobs.getJob({ jobId }); if (response.status === "completed") { return response.result; } else if (response.status === "failed") { throw new Error(`Job failed: ${response.error}`); } else if (response.status === "canceled") { throw new Error("Job was canceled"); } // Still pending or processing - wait and retry console.log(`Status: ${response.status}, waiting ${delay}s...`); await new Promise(resolve => setTimeout(resolve, delay * 1000)); delay = Math.min(delay * 1.5, 10); // Cap at 10 seconds } throw new Error("Polling timeout"); } // Example usage const jobId = "abc123-def456-ghi789"; pollJob(jobId).then(result => { console.log(`Extraction complete! Markdown: ${result.markdown?.slice(0, 100)}...`); }); ``` ```bash curl theme={null} #!/bin/bash JOB_ID="abc123-def456-ghi789" API_KEY="YOUR_API_KEY" MAX_ATTEMPTS=60 DELAY=1 for ((attempt=1; attempt<=MAX_ATTEMPTS; attempt++)); do response=$(curl -s "https://api.runpulse.com/job/${JOB_ID}" \ -H "x-api-key: ${API_KEY}") status=$(echo "$response" | jq -r '.status') case "$status" in "completed") echo "Job completed!" echo "$response" | jq '.result' exit 0 ;; "failed") error=$(echo "$response" | jq -r '.error') echo "Job failed: $error" exit 1 ;; "canceled") echo "Job was canceled" exit 1 ;; *) echo "Status: $status, waiting ${DELAY}s... (attempt $attempt)" sleep $DELAY DELAY=$(echo "$DELAY * 1.5" | bc) if (( $(echo "$DELAY > 10" | bc -l) )); then DELAY=10 fi ;; esac done echo "Polling timeout" exit 1 ``` ## Example Usage ### Check Job Status ```python Python SDK theme={null} from pulse import Pulse client = Pulse(api_key="YOUR_API_KEY") # Check job status job_id = "abc123-def456-ghi789" response = client.jobs.get_job(job_id=job_id) print(f"Job ID: {response.job_id}") print(f"Status: {response.status}") print(f"Created: {response.created_at}") if response.status == "completed": print(f"Markdown: {response.result['markdown'][:100]}...") elif response.status == "failed": print(f"Error: {response.error}") ``` ```typescript TypeScript SDK theme={null} import { PulseClient } from 'pulse-ts-sdk'; const client = new PulseClient({ apiKey: 'YOUR_API_KEY' }); // Check job status const jobId = "abc123-def456-ghi789"; const response = await client.jobs.getJob({ jobId }); console.log(`Job ID: ${response.job_id}`); console.log(`Status: ${response.status}`); console.log(`Created: ${response.created_at}`); if (response.status === "completed") { console.log(`Markdown: ${response.result?.markdown?.slice(0, 100)}...`); } else if (response.status === "failed") { console.log(`Error: ${response.error}`); } ``` ```bash curl theme={null} # Check job status curl https://api.runpulse.com/job/abc123-def456-ghi789 \ -H "x-api-key: YOUR_API_KEY" ``` ### Complete Async Workflow ```python Python SDK theme={null} from pulse import Pulse import time import json client = Pulse(api_key="YOUR_API_KEY") # Step 1: Submit async extraction print("Submitting async extraction...") submit_response = client.extract( file_url="https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf", async_=True ) job_id = submit_response.job_id print(f"Job submitted: {job_id}") # Step 2: Poll for completion delay = 1 while True: status_response = client.jobs.get_job(job_id=job_id) if status_response.status == "completed": print("Extraction complete!") extraction_id = status_response.result["extraction_id"] print(f"Extraction ID: {extraction_id}") break elif status_response.status in ["failed", "canceled"]: print(f"Job ended with status: {status_response.status}") break print(f"Status: {status_response.status}") time.sleep(delay) delay = min(delay * 1.5, 10) # Step 3 (optional): Apply schema via /schema endpoint schema_result = client.schema( extraction_id=extraction_id, schema_config={ "input_schema": { "type": "object", "properties": { "account_holder": {"type": "string"}, "balance": {"type": "number"} } } } ) print(f"Schema output: {schema_result.schema_output}") ``` ```typescript TypeScript SDK theme={null} import { PulseClient } from 'pulse-ts-sdk'; const client = new PulseClient({ apiKey: 'YOUR_API_KEY' }); // Step 1: Submit async extraction console.log("Submitting async extraction..."); const submitResponse = await client.extract({ fileUrl: "https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf", async: true }); const jobId = submitResponse.job_id; console.log(`Job submitted: ${jobId}`); // Step 2: Poll for completion let delay = 1; let extractionId: string; while (true) { const statusResponse = await client.jobs.getJob({ jobId }); if (statusResponse.status === "completed") { console.log("Extraction complete!"); extractionId = statusResponse.result?.extraction_id; console.log(`Extraction ID: ${extractionId}`); break; } else if (statusResponse.status === "failed" || statusResponse.status === "canceled") { console.log(`Job ended with status: ${statusResponse.status}`); break; } console.log(`Status: ${statusResponse.status}`); await new Promise(resolve => setTimeout(resolve, delay * 1000)); delay = Math.min(delay * 1.5, 10); } // Step 3 (optional): Apply schema via /schema endpoint const schemaResult = await client.schema({ extraction_id: extractionId, schema_config: { input_schema: { type: "object", properties: { account_holder: { type: "string" }, balance: { type: "number" } } } } }); console.log(`Schema output:`, schemaResult.schema_output); ``` ```bash curl theme={null} # Step 1: Submit async extraction JOB_RESPONSE=$(curl -s -X POST https://api.runpulse.com/extract \ -H "x-api-key: YOUR_API_KEY" \ -F "file_url=https://platform.runpulse.com/api/examples/637e5678-30b1-45fa-acc4-877f2d636419/pdf" \ -F "async=true") JOB_ID=$(echo "$JOB_RESPONSE" | jq -r '.job_id') echo "Job submitted: $JOB_ID" # Step 2: Poll for completion DELAY=1 while true; do STATUS_RESPONSE=$(curl -s "https://api.runpulse.com/job/${JOB_ID}" \ -H "x-api-key: YOUR_API_KEY") STATUS=$(echo "$STATUS_RESPONSE" | jq -r '.status') case "$STATUS" in "completed") echo "Extraction complete!" EXTRACTION_ID=$(echo "$STATUS_RESPONSE" | jq -r '.result.extraction_id') echo "Extraction ID: $EXTRACTION_ID" break ;; "failed"|"canceled") echo "Job ended with status: $STATUS" break ;; *) echo "Status: $STATUS" sleep $DELAY DELAY=$(echo "$DELAY * 1.5" | bc) if (( $(echo "$DELAY > 10" | bc -l) )); then DELAY=10 fi ;; esac done ``` For webhook-based notifications instead of polling, see the [Webhooks](/api-reference/endpoint/webhook) documentation. # Get Result Image Source: https://docs.runpulse.com/api-reference/endpoint/results-image GET /results/{jobId}/images/{filename} Stream a PNG/JPEG visual image referenced by an extraction response under `bounding_boxes.Images[].image_url`. The URL is API-hosted instead of raw S3 — the underlying object store is intentionally not part of the public contract. The host in `image_url` mirrors the request origin (e.g. a request to a beta deployment returns image URLs on that same host). **Authentication is required.** Unlike single-use result download links, visual artifacts are independently-addressable resources — every fetch must present a valid API key for the owning org. There is no anonymous / TTL-based fallback. Use the same `x-api-key` header you use for `/extract`. Fetching an image does **not** consume the parent extraction's result-delivery slot, so one extraction can produce many image URLs and each can be fetched repeatedly while the artifact is retained. ## Overview Fetch a PNG or JPEG visual image referenced by an extraction response under `bounding_boxes.Images[].image_url`. When you call [`/extract`](/api-reference/endpoint/extract) with `figure_processing.show_images: true`, every detected chart or embedded image in the response carries an `image_url` field. Those URLs point at this endpoint — `GET /results/{jobId}/images/{filename}` — which streams the actual image bytes. ```json theme={null} { "bounding_boxes": { "Images": [ { "id": "excel_image_1_1", "visual_type": "chart", "image_url": "https://api.runpulse.com/results/13e3e75f-.../images/excel_image_1_1.png", "chart_type": "BarChart", "chart_title": "Revenue", "excel_range": "D2", "sheet_name": "Charts" } ] } } ``` This endpoint is most useful for **spreadsheet** extractions, where charts and embedded images are read directly from the workbook. For PDFs and image inputs, the same shape applies whenever figure detection is enabled. ## When to use this vs. `/large_results/{jobId}` | Endpoint | Purpose | Auth | Single-use? | | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------- | | `GET /large_results/{jobId}` | Download the **full extraction result** (markdown + bounding\_boxes + …) when the inline payload exceeds 5 MB or 70 pages. | Anonymous within 1-hour TTL or authenticated same-org. | Yes — fetching invalidates the link. | | `GET /results/{jobId}/images/{filename}` | Download **one visual image** referenced by `bounding_boxes.Images[].image_url`. | Authenticated same-org only. No anonymous access. | No — fetch as many times as you need while the artifact is retained. | Fetching a visual image **does not** consume the parent extraction's result-delivery slot, because a single extraction can contain many image URLs. ## End-to-End Example The full path: extract a workbook → walk the typed `Images` array → fetch one chart's bytes. ```python Python theme={null} from pulse import Pulse from pulse.types import ExtractRequestFigureProcessing client = Pulse(api_key="YOUR_API_KEY") # 1) Extract a workbook and ask for image URLs. response = client.extract( file=open("financials.xlsx", "rb"), figure_processing=ExtractRequestFigureProcessing( show_images=True, description=False, ), ) # 2) Walk the typed Images array. for img in response.bounding_boxes.images or []: print(img.id, img.visual_type, img.chart_title, img.image_url) # 3) Fetch the bytes for the first chart. import re img = response.bounding_boxes.images[0] m = re.search(r"/results/([^/]+)/images/([^/?#]+)", img.image_url) job_id, filename = m.group(1), m.group(2) # `get_image` returns an iterator of byte chunks — join to get the full PNG. chunks = list(client.results.get_image(job_id=job_id, filename=filename)) png_bytes = b"".join(chunks) with open("chart.png", "wb") as f: f.write(png_bytes) ``` ```typescript TypeScript theme={null} import { PulseClient } from "pulse-ts-sdk"; import * as fs from "node:fs"; const client = new PulseClient({ apiKey: "YOUR_API_KEY" }); // 1) Extract a workbook and ask for image URLs. const response = await client.extract({ file: fs.createReadStream("financials.xlsx"), figureProcessing: { showImages: true }, }); // 2) Walk the typed Images array. for (const img of response.boundingBoxes?.Images ?? []) { console.log(img.id, img.visualType, img.chartTitle, img.imageUrl); } // 3) Fetch the bytes for the first chart. const url = response.boundingBoxes?.Images?.[0]?.imageUrl; const m = url?.match(/\/results\/([^/]+)\/images\/([^/?#]+)/); const [, jobId, filename] = m!; const image = await client.results.getImage({ jobId, filename }); // image is a binary response — consume per your runtime (e.g. `await image.bytes()`). ``` ```bash curl theme={null} # Step 1: extract and capture the image_url. curl -sS -X POST https://api.runpulse.com/extract \ -H "x-api-key: $PULSE_API_KEY" \ -F "file=@financials.xlsx" \ -F 'figure_processing={"show_images": true}' \ | jq -r '.bounding_boxes.Images[0].image_url' # -> https://api.runpulse.com/results/13e3e75f-.../images/excel_image_1_1.png # Step 2: fetch the PNG bytes. curl -sS -X GET "https://api.runpulse.com/results/13e3e75f-.../images/excel_image_1_1.png" \ -H "x-api-key: $PULSE_API_KEY" \ -o chart.png ``` ## Authentication **Every request must present a valid `x-api-key` header for the org that owns the extraction.** Unlike the legacy single-use `/large_results/{jobId}` route, visual artifacts are independently-addressable resources — there is no anonymous fallback or short-lived public link. * **Authenticated same-org calls** (your `x-api-key` matches the org that produced the extraction): succeed for as long as the underlying artifact is retained — same window as any other extraction artifact for that org. * **Missing credentials** (no `x-api-key` header): rejected with `401 Unauthorized` (`AUTH_001`). * **Cross-org authenticated calls** (valid key, but not the owning org): rejected with `403 Forbidden` (`AUTH_002`). Use the same key configuration as your other Pulse SDK calls — the SDK's `Pulse(api_key=...)` / `new PulseClient({ apiKey: ... })` constructor will attach `x-api-key` to every `results.getImage` fetch automatically. Embedding `image_url` directly in **public** UIs (e.g. a server-rendered HTML page exposed to unauthenticated visitors) will fail with `401`. For public/anonymous embeds, fetch the bytes server-side using your API key and re-host them, or proxy them through your own auth layer. Repeated fetches against the same `image_url` are fine — the link is multi-use. Fetching does **not** consume the parent extraction's result-delivery slot, so one extraction can produce many image URLs and each can be downloaded as many times as needed. ## Errors | Status | Code | Meaning | | ------------------ | ----------------- | -------------------------------------------------------------------------------------------------------------- | | `400 Bad Request` | `INVALID_REQUEST` | The `filename` path segment failed safe-filename validation. | | `401 Unauthorized` | `AUTH_001` | No `x-api-key` (or no valid signed-in session) was supplied. | | `403 Forbidden` | `AUTH_002` | The caller is authenticated but does not belong to the org that owns this extraction. | | `404 Not Found` | `NOT_FOUND` | Job or visual image not found. The `jobId` or `filename` is wrong, or the artifact has been garbage-collected. | ## Next Steps Full reference for the `Images`, `Tables`, `Text`, `Title`, and `Footer` arrays. Enable `figure_processing.show_images` to populate `image_url`. # Download Result PDF Source: https://docs.runpulse.com/api-reference/endpoint/results-pdf GET /results/{jobId}/pdf Download the PDF binary produced by a `/form/detect`, `/form/fill`, or `/form/clear` job. The `pdf_url` field on a `FormResult` points at this endpoint — you can hand it directly to a browser, embed it in an `