*REST API › Tutorials*

# Turn a web form into an opportunity

> Your server receives the form, finds or creates the contact by email, and opens an opportunity in the pipeline, with a key derived from the submission so that no retry creates a second lead.

## What you build

- A handler on your server that takes one form submission and produces one contact (found or created) and one opportunity.
- Retries that are safe at every step, because each write carries an `Idempotency-Key` derived from the submission id.
- Validation errors that come back to the visitor on the right form field, and failures that never lose the lead.

> **Server side only:** The API key acts as the administrator who created it and carries every scope you gave it. Anything in the page's JavaScript is public to every visitor, so the form posts to your server and only your server calls Filmo.

## Before you start

| Scope | Used for |
| --- | --- |
| `account:read` | The first call: timezone, add-ons and the scopes of the key. |
| `contacts:read` | Finding the contact by email. |
| `contacts:write` | Creating the contact. |
| `crm:read` | Listing pipelines and stages. Only needed if you send opportunities to a named pipeline or stage (optional step). |
| `crm:write` | Creating the opportunity. |

- Opportunities and pipelines need the CRM add-on. Without it the CRM operations answer 402 [`addon_required`](https://docs.filmo.io/rest-api/errors/addon_required).
- Generate one id per rendered form, such as a UUID in a hidden field or in the server-side session. It is the submission id, and the keys below derive from it.
- Set up the client from the first tutorial's step 1 (the same two files).

## 1. Check the key

Call [`GET /api/v1/account`](https://docs.filmo.io/rest-api/endpoint-reference/account/get-the-account) once at deploy time or on a health check, not per submission. Confirm that `addons` includes what the CRM needs and that `apiKey.scopes` has the scopes above.

**cURL**

```bash
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS "$BASE/api/v1/account" \
  -H "Authorization: Bearer $FILMO_API_KEY"
```

**Node**

```javascript
import { filmo } from "./filmo-client.mjs";

const res = await filmo("GET", "/api/v1/account");
if (!res.ok) throw new Error("getAccount " + res.status + " " + res.body?.code);

const { timezone, currency, addons, trial, apiKey } = res.body.data;
console.log({ timezone, currency, addons, trial: trial.active, scopes: apiKey.scopes });
```

**Python**

```python
from filmo_client import filmo

status, body, _ = filmo("GET", "/api/v1/account")
if status != 200:
    raise RuntimeError(f"getAccount {status} {(body or {}).get('code')}")

account = body["data"]
print(account["timezone"], account["currency"], account["addons"],
      account["trial"]["active"], account["apiKey"]["scopes"])
```

## 2. Validate the submission yourself first

Check the required fields, the email format and the lengths on your server before calling Filmo, and filter spam there too. Every call you avoid counts: the key has 120 requests per minute for everything it does. The body limits you need are `name` up to 200 characters, `email` up to 200 and `phone` up to 50 for the contact, and 200 characters for the opportunity `name` and for each of `sourceDetail`, `utmSource`, `utmMedium` and `utmCampaign`.

## 3. Find the contact by email

Call [`GET /api/v1/contacts`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/list-contacts) with [`email`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/list-contacts#param-email). The spec does not say whether the filter matches exactly or in part, so compare the addresses in `emails[].email` yourself and keep only exact matches. If several contacts match, pick one deterministically (the code prefers an active one, then the oldest) and let the team clean the duplicates; the visitor should not see it.

**cURL**

```bash
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS -G "$BASE/api/v1/contacts" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  --data-urlencode "email=ana@example.com" \
  --data-urlencode "limit=500"
```

**Node**

```javascript
import { filmo } from "./filmo-client.mjs";

// Exact match on any address of the contact; the oldest active one wins, deterministically.
export async function findContact(email) {
  const res = await filmo("GET", "/api/v1/contacts", { query: { email, limit: 500 } });
  if (!res.ok) return { problem: res };
  const hits = res.body.data
    .filter((c) => c.emails.some((e) => e.email.toLowerCase() === email.toLowerCase()))
    .sort((a, b) => (a.status === "active" ? 0 : 1) - (b.status === "active" ? 0 : 1)
      || Date.parse(a.createdAt) - Date.parse(b.createdAt));
  return { contact: hits[0] ?? null };
}
```

**Python**

```python
from datetime import datetime

from filmo_client import filmo


# Exact match on any address of the contact; the oldest active one wins, deterministically.
def find_contact(email):
    status, body, _ = filmo("GET", "/api/v1/contacts", params={"email": email, "limit": 500})
    if status != 200:
        return {"problem": (status, body)}
    hits = [c for c in body["data"] if any(e["email"].lower() == email.lower() for e in c["emails"])]
    hits.sort(key=lambda c: (c["status"] != "active", datetime.fromisoformat(c["createdAt"].replace("Z", "+00:00"))))
    return {"contact": hits[0] if hits else None}
```

> **Two simultaneous submissions:** The spec does not say that an email is unique among contacts, and the `Idempotency-Key` only deduplicates repeats of the same submission. Two different submissions from the same person at the same moment can both find nothing and both create a contact. Serialise the find-or-create per email on your side (a lock or a queue keyed by the lower-cased address) if that matters to you.

## 4. Create the contact if there is none

[`POST /api/v1/contacts`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/create-a-contact) requires only `name`. A form usually has a role of `lead`; the roles are `client`, `provider`, `lead`, `debtor`, `creditor`, `student`, `teacher` and `unspecified`. The key is `web-form-<submission id>-contact`: if the response is lost and you repeat the call, you receive the first response with `Idempotent-Replay: true` and no second contact exists.

**cURL**

```bash
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS -X POST "$BASE/api/v1/contacts" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: web-form-7c1e4a52-contact" \
  --data @- <<'JSON'
{
  "name": "Ana Example",
  "contactType": "person",
  "role": "lead",
  "email": "ana@example.com",
  "phone": "+34 600 000 000"
}
JSON
```

**Node**

```javascript
import { filmo } from "./filmo-client.mjs";

// submission.id: the id of THIS form submission, generated when the form was rendered.
export const createContact = (submission) =>
  filmo("POST", "/api/v1/contacts", {
    idempotencyKey: "web-form-" + submission.id + "-contact",
    body: {
      name: submission.name,
      contactType: "person",
      role: "lead",
      email: submission.email,
      phone: submission.phone,
    },
  });
```

**Python**

```python
from filmo_client import filmo


# submission["id"]: the id of THIS form submission, generated when the form was rendered.
def create_contact(submission):
    body = {
        "name": submission["name"],
        "contactType": "person",
        "role": "lead",
        "email": submission["email"],
        "phone": submission.get("phone"),
    }
    body = {k: v for k, v in body.items() if v}
    return filmo(
        "POST", "/api/v1/contacts", body=body,
        idempotency_key="web-form-" + submission["id"] + "-contact",
    )
```

Save steps 3 and 4 together as `contact.mjs` (Python: `contact.py`), step 5 as `pipelines.mjs` (`pipelines.py`) and step 6 as `opportunity.mjs` (`opportunity.py`); the handler in step 7 imports them. If the form also asks for a company, create a second contact with `contactType` `company` the same way and pass its id as `companyId` in the opportunity. The tutorial keeps to one contact.

## 5. Pick the pipeline and stage (optional)

If you omit `pipelineId`, Filmo uses the default pipeline of the account; if you omit `stageId`, the first stage of that pipeline. Do this step only when web leads must go to a specific pipeline or stage. [`GET /api/v1/crm/pipelines`](https://docs.filmo.io/rest-api/endpoint-reference/crm/list-pipelines) returns every active pipeline with its ordered stages in one response (it does not paginate; archived ones need `includeArchived`). Resolve the names to ids once and cache them for an hour; the handler in step 7 refreshes them after a 400.

**cURL**

```bash
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS "$BASE/api/v1/crm/pipelines" \
  -H "Authorization: Bearer $FILMO_API_KEY"
```

**Node**

```javascript
import { filmo } from "./filmo-client.mjs";

let cached = null; // { at, pipelineId, stageId }

// Optional: with no pipeline or stage name set, send neither id (Filmo uses the default pipeline and
// its first stage) and never call listPipelines, so the key does not need crm:read.
export async function target({ refresh = false } = {}) {
  if (!process.env.FILMO_PIPELINE_NAME && !process.env.FILMO_STAGE_NAME) return {};
  if (cached && !refresh && Date.now() - cached.at < 3_600_000) return cached;
  const res = await filmo("GET", "/api/v1/crm/pipelines");
  if (!res.ok) throw new Error("listPipelines " + res.status + " " + res.body?.requestId);

  const pipelines = res.body.data;
  const pipeline =
    pipelines.find((p) => p.name === process.env.FILMO_PIPELINE_NAME) ??
    pipelines.find((p) => p.isDefault);
  const stage = pipeline?.stages.find((s) => s.name === process.env.FILMO_STAGE_NAME && s.type === "open");
  cached = { at: Date.now(), pipelineId: pipeline?.pipelineId, stageId: stage?.stageId };
  return cached;
}
```

**Python**

```python
import os
import time

from filmo_client import filmo

_cached = None  # {"at", "pipeline_id", "stage_id"}


# Optional: with no pipeline or stage name set, send neither id (Filmo uses the default pipeline and
# its first stage) and never call listPipelines, so the key does not need crm:read.
def target(refresh=False):
    global _cached
    if not os.environ.get("FILMO_PIPELINE_NAME") and not os.environ.get("FILMO_STAGE_NAME"):
        return {"pipeline_id": None, "stage_id": None}
    if _cached and not refresh and time.time() - _cached["at"] < 3600:
        return _cached
    status, body, _ = filmo("GET", "/api/v1/crm/pipelines")
    if status != 200:
        raise RuntimeError(f"listPipelines {status} {(body or {}).get('requestId')}")

    pipelines = body["data"]
    pipeline = next((p for p in pipelines if p["name"] == os.environ.get("FILMO_PIPELINE_NAME")), None)           or next((p for p in pipelines if p["isDefault"]), None)
    stage = next((s for s in (pipeline or {}).get("stages", [])
                  if s["name"] == os.environ.get("FILMO_STAGE_NAME") and s["type"] == "open"), None)
    _cached = {
        "at": time.time(),
        "pipeline_id": pipeline["pipelineId"] if pipeline else None,
        "stage_id": stage["stageId"] if stage else None,
    }
    return _cached
```

## 6. Create the opportunity

[`POST /api/v1/crm/opportunities`](https://docs.filmo.io/rest-api/endpoint-reference/crm/create-an-opportunity) requires only `name`. Add the `contactId`, put the visitor's message in `description` and record where the lead came from: `source` is an enum, and `website_form` is the value for this case (`source` lists the others). The `utm*` fields keep the campaign attribution. The key is `web-form-<submission id>-opportunity`.

**cURL**

```bash
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS -X POST "$BASE/api/v1/crm/opportunities" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: web-form-7c1e4a52-opportunity" \
  --data @- <<JSON
{
  "name": "Website enquiry from Ana Example",
  "description": "We need two cameras and a light kit for 2 to 4 November.",
  "contactId": "$CONTACT_ID",
  "source": "website_form",
  "sourceDetail": "/rent/cameras",
  "utmSource": "newsletter",
  "utmMedium": "email",
  "utmCampaign": "autumn-shoots"
}
JSON
```

**Node**

```javascript
import { filmo } from "./filmo-client.mjs";
import { target } from "./pipelines.mjs";

const clamp = (text, max) => (text == null ? undefined : String(text).slice(0, max));

// attempt 2 is the repeat with refreshed pipeline ids: another body needs another key.
export async function createOpportunity(submission, contactId, { attempt = 1 } = {}) {
  const { pipelineId, stageId } = await target();
  return filmo("POST", "/api/v1/crm/opportunities", {
    idempotencyKey: "web-form-" + submission.id + "-opportunity" + (attempt > 1 ? "-" + attempt : ""),
    body: {
      name: clamp("Website enquiry from " + submission.name, 200),
      description: submission.message,
      contactId,
      pipelineId,
      stageId,
      source: "website_form",
      sourceDetail: clamp(submission.page, 200),
      utmSource: clamp(submission.utm?.source, 200),
      utmMedium: clamp(submission.utm?.medium, 200),
      utmCampaign: clamp(submission.utm?.campaign, 200),
    },
  });
}
```

**Python**

```python
from filmo_client import filmo
from pipelines import target


def clamp(text, limit):
    return None if text is None else str(text)[:limit]


# attempt 2 is the repeat with refreshed pipeline ids: another body needs another key.
def create_opportunity(submission, contact_id, attempt=1):
    t = target()
    utm = submission.get("utm") or {}
    body = {
        "name": clamp("Website enquiry from " + submission["name"], 200),
        "description": submission.get("message"),
        "contactId": contact_id,
        "pipelineId": t["pipeline_id"],
        "stageId": t["stage_id"],
        "source": "website_form",
        "sourceDetail": clamp(submission.get("page"), 200),
        "utmSource": clamp(utm.get("source"), 200),
        "utmMedium": clamp(utm.get("medium"), 200),
        "utmCampaign": clamp(utm.get("campaign"), 200),
    }
    body = {k: v for k, v in body.items() if v is not None}
    return filmo(
        "POST", "/api/v1/crm/opportunities", body=body,
        idempotency_key="web-form-" + submission["id"] + "-opportunity" + (f"-{attempt}" if attempt > 1 else ""),
    )
```

Because both writes use keys derived from the same submission, the whole handler can be run again from the start: the find step now sees the contact (or the create replays), and the opportunity replays. The keys last 24 hours; if you retry after that, check your own record of what was created before calling again.

> **Automations:** Writes through the API fire the account's event workflows, like the same change made in the app. They run after the response, on a best-effort basis, and you cannot suppress them per request. List the active event workflows first (see [Testing](https://docs.filmo.io/rest-api/guides/testing)), tell the administrator that web leads arrive through the API, and filter the trigger of any workflow that should not react by origin to exclude `api` and `mcp`.

## 7. Map errors to the form, and never lose the lead

A 400 [`validation_error`](https://docs.filmo.io/rest-api/errors/validation_error) lists every offending field in `errors[]`, with a `path` (such as `email`) and a stable `code`. Map the paths you know to your form fields and show your own text for each `code`. The `message` is written for people in the language of the request or the account: do not use it for logic.

*A 400 for the contact (abridged)*

```json
{
  "type": "https://docs.filmo.io/errors/validation_error",
  "status": 400,
  "code": "validation_error",
  "errors": [
    { "path": "email", "code": "invalid_format", "message": "..." },
    { "path": "phone", "code": "too_big", "message": "..." }
  ],
  "requestId": "req_..."
}
```

Two kinds of error need different treatment. A path that belongs to something the visitor typed (`email`, `phone`) goes back to the form. Anything else, including a path your code set (`contactId`, `source`), an empty path for the whole body, or an entry with no `path` at all, is your bug, not the visitor's: queue the submission, alert yourself with the `requestId`, and tell the visitor you received the request. The 400 of a CRM write does not always say which field failed, so you cannot read a stale `pipelineId` or `stageId` from it. The handler reads the pipelines again after a 400 and repeats the write once only if the ids it would send have changed.

*The whole handler*

**Node**

```javascript
import { createContact, findContact } from "./contact.mjs";
import { createOpportunity } from "./opportunity.mjs";
import { target } from "./pipelines.mjs";

// API path -> form field, per call. A path that is not here is a bug on your side, not the visitor's.
const CONTACT_FIELDS = { name: "name", email: "email", phone: "phone" };
const OPPORTUNITY_FIELDS = { description: "message" };

function visitorErrors(problem, fieldMap) {
  const fields = {};
  let ours = false;
  for (const e of problem.errors ?? []) {
    if (fieldMap[e.path]) fields[fieldMap[e.path]] = e.code; // show your own text for the code
    else ours = true; // includes the empty path (the whole body)
  }
  return { fields, ours };
}

// Never drop a lead: anything that is not the visitor's mistake goes to your outbox.
const queued = (submission, why) => {
  // outbox.save(submission, why) with your own storage, then retry with the SAME submission id
  return { status: 202, body: { received: true } };
};

export async function handleSubmission(submission) {
  try {
    return await processSubmission(submission);
  } catch (error) {
    // A network cut or a timeout throws before any answer arrives: the lead still goes to the outbox.
    return queued(submission, error);
  }
}

async function processSubmission(submission) {
  let found = await findContact(submission.email);
  if (found.problem) return queued(submission, found.problem);
  let contact = found.contact;

  if (!contact) {
    const res = await createContact(submission);
    if (res.status === 400) {
      const { fields, ours } = visitorErrors(res.body, CONTACT_FIELDS);
      return ours || !Object.keys(fields).length ? queued(submission, res.body) : { status: 400, body: { fields } };
    }
    if (!res.ok) return queued(submission, res.body);
    contact = res.body.data;
  }

  let res = await createOpportunity(submission, contact.id);
  if (res.status === 400) {
    // The 400 of a CRM write may carry no `path`, so it cannot tell you that a cached id went stale.
    // Read the pipelines again and repeat the write only if the ids you would send are different now.
    const before = await target();
    const after = await target({ refresh: true });
    if (after.pipelineId !== before.pipelineId || after.stageId !== before.stageId) {
      res = await createOpportunity(submission, contact.id, { attempt: 2 });
    }
  }
  if (res.status === 400) {
    const { fields, ours } = visitorErrors(res.body, OPPORTUNITY_FIELDS);
    return ours || !Object.keys(fields).length ? queued(submission, res.body) : { status: 400, body: { fields } };
  }
  if (!res.ok) return queued(submission, res.body);
  return { status: 201, body: { received: true } };
}
```

**Python**

```python
from contact import create_contact, find_contact
from opportunity import create_opportunity
from pipelines import target

# API path -> form field, per call. A path that is not here is a bug on your side, not the visitor's.
CONTACT_FIELDS = {"name": "name", "email": "email", "phone": "phone"}
OPPORTUNITY_FIELDS = {"description": "message"}


def visitor_errors(problem, field_map):
    fields, ours = {}, False
    for e in problem.get("errors") or []:
        if e["path"] in field_map:
            fields[field_map[e["path"]]] = e["code"]  # show your own text for the code
        else:
            ours = True  # includes the empty path (the whole body)
    return fields, ours


# Never drop a lead: anything that is not the visitor's mistake goes to your outbox.
def queued(submission, why):
    # outbox.save(submission, why) with your own storage, then retry with the SAME submission id
    return 202, {"received": True}


def handle_submission(submission):
    try:
        return _process_submission(submission)
    except Exception as error:  # a network cut or a timeout raises before any answer arrives
        return queued(submission, error)


def _process_submission(submission):
    found = find_contact(submission["email"])
    if "problem" in found:
        return queued(submission, found["problem"])
    contact = found["contact"]

    if contact is None:
        status, body, _ = create_contact(submission)
        if status == 400:
            fields, ours = visitor_errors(body, CONTACT_FIELDS)
            return queued(submission, body) if ours or not fields else (400, {"fields": fields})
        if status != 201:
            return queued(submission, body)
        contact = body["data"]

    status, body, _ = create_opportunity(submission, contact["id"])
    if status == 400:
        # The 400 of a CRM write may carry no path, so it cannot tell you that a cached id went stale.
        # Read the pipelines again and repeat the write only if the ids you would send are different now.
        before = target()
        after = target(refresh=True)
        if (after["pipeline_id"], after["stage_id"]) != (before["pipeline_id"], before["stage_id"]):
            status, body, _ = create_opportunity(submission, contact["id"], attempt=2)
    if status == 400:
        fields, ours = visitor_errors(body, OPPORTUNITY_FIELDS)
        return queued(submission, body) if ours or not fields else (400, {"fields": fields})
    if status != 201:
        return queued(submission, body)
    return 201, {"received": True}
```

## Handle the other responses

| Response | What to do |
| --- | --- |
| 429 `rate_limited` | Wait `Retry-After` seconds (also in `retryAfter` of the problem body) and repeat the same request; the client does it with jitter up to five times. If it still fails, queue the submission and answer the visitor that you received it. Do not hold the visitor's request open for minutes. |
| 401 `api_key_owner_inactive`, `api_key_owner_not_admin` | The key's owner left or stopped being an administrator. Queue, alert and reissue the key. |
| 402 `addon_required` | The account has no CRM add-on. Queue and alert; `requiredAddon` names it. |
| 403 `insufficient_scope` | Queue and alert; `requiredScope` names the missing scope. |
| 403 `forbidden` | The owner of the key lacks the permission the operation requires. Queue and alert; fix it in the app. |
| 409 `conflict` | Read `errors[0].code`. `in_progress` (the same key is still running) and `lock_timeout` (the resource was locked by another write): wait and repeat with the same key. `invalid_state`: do not retry; queue and alert. |
| 422 `idempotency_key_reused` | You sent the same submission id with different content. A key identifies one operation; give changed content a new submission id. |
| 500 `internal_error` | Repeat with the same keys and growing waits; queue if it persists. If the answer carries `Idempotent-Replay: true`, the stored error is being replayed: stop repeating and look the contact up by email or `contactNumber` before creating again. |
| 503 `service_unavailable` | No operation of this tutorial documents it. If it arrives, Filmo controls the switch behind it: do not retry it as a transient failure. Queue the submission and alert with the `requestId`. |

## What to store on your side

| What | Why |
| --- | --- |
| Submission id | The root of both keys and the identity of the lead in your logs. |
| Filmo contact id and opportunity id | Proof of what was created, for retries after the keys expired and for support. |
| `requestId` of any failed call | What you quote to support. |
| The queued submissions | A lead that could not be sent yet. Replay them with the original submission id. |

## Where to go next

- [Sync contacts from your CRM](https://docs.filmo.io/rest-api/tutorials/crm-contact-sync): the same matching rules when the CRM owns the contacts.
- [From availability check to reserved order](https://docs.filmo.io/rest-api/tutorials/availability-to-reserved-order): a booking flow that checks stock before it asks for the lead.
- [Idempotency](https://docs.filmo.io/rest-api/guides/idempotency), [Errors](https://docs.filmo.io/rest-api/guides/errors) and [Rate limits](https://docs.filmo.io/rest-api/guides/rate-limits).
- [Going live](https://docs.filmo.io/rest-api/guides/going-live): the checklist before real visitors use the form.
- In the app: [Pipelines and stages](https://www.filmo.io/help/reference/clients-and-crm/crm/crm-pipelines-and-stages).

---

Canonical URL: https://docs.filmo.io/rest-api/tutorials/web-form-to-opportunity  
Last updated: 2026-10-09