Common Patterns

Patterns and best practices for working with the Findable Partner API, including pagination, document uploads, and error handling.

Pagination

List endpoints use cursor-based pagination. Pass a next_token query parameter to fetch the next page. The response includes a nextToken field — when it is null, you have reached the last page. Note the casing difference: the query parameter is snake_case (next_token) while the response field is camelCase (nextToken).

How it works

  1. Make the initial request without next_token
  2. Check the nextToken field in the response
  3. If not null, pass it as next_token in the next request
  4. Repeat until nextToken is null

Example: Iterate all documents

const API_KEY = 'YOUR_API_KEY'; // pragma: allowlist secret
const BASE_URL = 'https://api.findable.ai';

async function getAllDocuments(ownerId: string, buildingId: string) {
  const documents = [];
  let nextToken: string | null = null;

  do {
    const params = new URLSearchParams({ limit: '10' });
    if (nextToken) params.set('next_token', nextToken);

    const res = await fetch(
      `${BASE_URL}/building_owners/${ownerId}/buildings/${buildingId}/documents?${params}`,
      { headers: { 'x-api-key': API_KEY } },
    );
    const data = await res.json();
    documents.push(...data.documents);
    nextToken = data.nextToken;
  } while (nextToken);

  return documents;
}

Document Upload Workflow

Uploading a document is a two-step process: first request a pre-signed URL from the API, then PUT the file directly to that URL.

Step 1: Request upload URL

Send a PUT request with the filename in the body. The API returns a documentId and a pre-signed uploadUrl.

curl -X PUT \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"filename": "report.pdf"}' \
  "https://api.findable.ai/building_owners/cust-456/buildings/bld-789/documents"

Step 2: Upload the file

PUT the raw file content to the returned URL. Set the Content-Type header to match the file type.

# Set Content-Type to match your file (e.g. application/pdf, image/png, etc.)
curl -X PUT \
  -H "Content-Type: application/pdf" \
  --data-binary @report.pdf \
  "UPLOAD_URL_FROM_STEP_1"

Step 3: Check processing status

After uploading, the document is processed asynchronously. Poll the status endpoint to check when processing is complete.

curl -H "x-api-key: YOUR_API_KEY" \
  "https://api.findable.ai/building_owners/cust-456/buildings/bld-789/documents/doc-123/status"

Full TypeScript example (Node.js)

import { readFile } from 'node:fs/promises';

const API_KEY = 'YOUR_API_KEY'; // pragma: allowlist secret

async function uploadDocument(ownerId: string, buildingId: string, filePath: string) {
  const filename = filePath.split('/').pop()!;
  const fileBuffer = await readFile(filePath);

  // Step 1: Get upload URL
  const res = await fetch(
    `https://api.findable.ai/building_owners/${ownerId}/buildings/${buildingId}/documents`,
    {
      method: 'PUT',
      headers: { 'x-api-key': API_KEY, 'Content-Type': 'application/json' },
      body: JSON.stringify({ filename }),
    },
  );
  if (!res.ok) {
    let message = res.statusText;
    try { message = (await res.json()).message ?? message; } catch {} // non-JSON body
    throw new Error(`Failed to request upload URL: ${res.status} ${message}`);
  }
  const { documentId, uploadUrl } = await res.json();

  // Step 2: Upload file — set Content-Type to match your file
  const ext = filename.split('.').pop()?.toLowerCase();
  const contentType = ext === 'pdf' ? 'application/pdf'
    : ext === 'png' ? 'image/png'
    : ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
    : 'application/octet-stream';
  await fetch(uploadUrl, {
    method: 'PUT',
    headers: { 'Content-Type': contentType },
    body: fileBuffer,
  });

  // Step 3: Poll status (timeout after 5 minutes)
  const maxAttempts = 60;
  let attempts = 0;
  let status;
  while (true) {
    const statusRes = await fetch(
      `https://api.findable.ai/building_owners/${ownerId}/buildings/${buildingId}/documents/${documentId}/status`,
      { headers: { 'x-api-key': API_KEY } },
    );
    status = await statusRes.json();
    if (status.categorised) break;
    if (status.text_extraction_error || status.invalid_file) {
      throw new Error(`Processing failed for document ${documentId}`);
    }
    if (++attempts >= maxAttempts) throw new Error('Processing timed out');
    await new Promise((r) => setTimeout(r, 5000));
  }

  return { documentId, status };
}

Error Handling

Error responses follow a consistent format with an HTTP status code and a JSON body containing a message field.

Error response format

{
  "message": "Building owner with ID cust-999 not found"
}
StatusDescription
400Bad Request — Invalid parameters or request body
401Unauthorised — Missing or invalid API key
403Forbidden — API key does not have access to this resource
404Not Found — The requested resource does not exist
409Conflict — The resource is in a conflicting state (e.g. active processing)
429Too Many Requests — Rate limit exceeded. Back off and retry after the delay indicated in the Retry-After header.
500Internal Server Error — An unexpected error occurred

Example: Error handling in TypeScript

async function apiRequest(url: string) {
  const res = await fetch(url, {
    headers: { 'x-api-key': 'YOUR_API_KEY' }, // pragma: allowlist secret
  });

  if (!res.ok) {
    let message = res.statusText;
    try { message = (await res.json()).message ?? message; } catch {} // non-JSON body
    throw new Error(`API error ${res.status}: ${message}`);
  }

  return res.json();
}