> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ouraicalling.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Integration examples

> Common patterns for wiring the API into your own backend: proxying keys, retrying safely, receiving webhooks, and batch calling

These are recipes for the same handful of problems most integrations run into, built against the real request and response shapes from the [API introduction](/api-reference/introduction). Adapt the endpoint, fields, and scopes to the resource you're working with — the patterns themselves (not the exact payloads) are what's worth reusing.

## Keep the API key off the client

Never call the API directly from browser or mobile code — that exposes your key to anyone who opens dev tools. Put a thin proxy in front of it instead: your frontend calls your own backend, and only your backend holds the Famulor key.

```js theme={null}
// server.js — Express route the frontend can safely call
import express from "express";

const app = express();
app.use(express.json());

app.post("/api/trigger-call", async (req, res) => {
  const { to_number, assistant_id, lead } = req.body;
  if (!to_number || !assistant_id) {
    return res.status(400).json({ error: "to_number and assistant_id are required" });
  }

  const famulorRes = await fetch("https://app.famulor.io/api/v1/calls", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.FAMULOR_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ to_number, assistant_id, lead }),
  });

  const body = await famulorRes.json();
  res.status(famulorRes.status).json(body);
});
```

The frontend never sees `FAMULOR_API_KEY` — it only talks to `/api/trigger-call` on your own domain.

## Retry with backoff

A `429` or a `5xx` is usually worth retrying, not failing on immediately. Back off between attempts instead of hammering the API:

```js theme={null}
async function famulorRequest(path, options = {}, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const res = await fetch(`https://app.famulor.io/api/v1${path}`, {
      ...options,
      headers: {
        Authorization: `Bearer ${process.env.FAMULOR_API_KEY}`,
        "Content-Type": "application/json",
        ...options.headers,
      },
    });

    if (res.ok) return res.json();
    if (![429, 500, 502, 503].includes(res.status) || attempt === maxRetries) {
      const body = await res.json().catch(() => ({}));
      throw new Error(body?.error?.message ?? `Request failed with ${res.status}`);
    }

    const delayMs = 2 ** attempt * 500 + Math.random() * 250;
    await new Promise((r) => setTimeout(r, delayMs));
  }
}
```

Each failed attempt roughly doubles the wait (500ms, 1s, 2s…) with a little random jitter so parallel callers don't all retry in lockstep.

## Receive webhooks

A workspace-level webhook signs every delivery with `X-Famulor-Signature: sha256=<hex digest>` — an HMAC-SHA256 of the **raw** request body using your webhook secret. (Assistant-level webhook URLs are unsigned and assistant-specific; see [Post-call webhooks](/assistants/webhooks) for the difference and the full payload.) Verify the signature before trusting the payload:

```js theme={null}
import express from "express";
import crypto from "node:crypto";

const app = express();

// express.raw() keeps the exact bytes — required for signature verification
app.post(
  "/webhooks/famulor",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const expected =
      "sha256=" +
      crypto.createHmac("sha256", process.env.FAMULOR_WEBHOOK_SECRET).update(req.body).digest("hex");
    const signature = req.header("X-Famulor-Signature") ?? "";
    const a = Buffer.from(signature);
    const b = Buffer.from(expected);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).send("invalid signature");
    }

    const event = JSON.parse(req.body.toString("utf8"));
    if (event.event === "call.completed") {
      // event.data.transcript, .analysis, .collected, .variables, ...
    }
    res.status(200).send("ok");
  }
);
```

<Warning>
  Verify against the raw bytes, before any JSON parsing. Re-serializing the body first can change whitespace or key order and silently break the signature check.
</Warning>

## Batch-call a CSV

To dial a list rather than one number, cap how many calls you fire at once — the API rejects requests once your credit hold or concurrency limit is reached, so an unbounded loop just produces a wall of errors instead of finishing faster.

```js theme={null}
import { parse } from "csv-parse/sync";
import { readFileSync } from "node:fs";

const rows = parse(readFileSync("leads.csv"), { columns: true });
const CONCURRENCY = 5;

async function processBatch(rows) {
  const results = [];
  for (let i = 0; i < rows.length; i += CONCURRENCY) {
    const batch = rows.slice(i, i + CONCURRENCY);
    const batchResults = await Promise.allSettled(
      batch.map((row) =>
        famulorRequest("/calls", {
          method: "POST",
          body: JSON.stringify({
            assistant_id: process.env.ASSISTANT_ID,
            to_number: row.phone,
            lead: { name: row.name, company: row.company },
          }),
        })
      )
    );
    results.push(...batchResults);
  }
  return results;
}
```

`famulorRequest` here is the retry-with-backoff helper above — batching gets you controlled concurrency, and the helper absorbs the occasional `429` without failing the whole run. For volume beyond a one-off script, a [Campaign](/campaigns/overview) or an [Automation](/automations/overview) triggered by CRM data usually needs less custom code to maintain than a batch script you have to keep running.
