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
Link to this section: 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-Keyderived 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
Link to this section: 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. - 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
Link to this section: 1. Check the keyCall GET /api once at deploy time or on a health check, not per submission. Confirm that addons includes what the CRM needs and that apiKey. has the scopes above.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS "$BASE/api/v1/account" \
-H "Authorization: Bearer $FILMO_API_KEY"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 });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
Link to this section: 2. Validate the submission yourself firstCheck 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
Link to this section: 3. Find the contact by emailCall GET /api with email. The spec does not say whether the filter matches exactly or in part, so compare the addresses in emails[]. 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.
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"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 };
}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
Link to this section: 4. Create the contact if there is nonePOST /api 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.
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"
}
JSONimport { 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,
},
});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. (Python: contact.), step 5 as pipelines. (pipelines.) and step 6 as opportunity. (opportunity.); 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)
Link to this section: 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 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.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS "$BASE/api/v1/crm/pipelines" \
-H "Authorization: Bearer $FILMO_API_KEY"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;
}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 _cached6. Create the opportunity
Link to this section: 6. Create the opportunityPOST /api 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.
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"
}
JSONimport { 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),
},
});
}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), 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
Link to this section: 7. Map errors to the form, and never lose the leadA 400 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.
{
"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.
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 } };
}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
Link to this section: 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].. 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
Link to this section: 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
Link to this section: Where to go next- Sync contacts from your CRM: the same matching rules when the CRM owns the contacts.
- From availability check to reserved order: a booking flow that checks stock before it asks for the lead.
- Idempotency, Errors and Rate limits.
- Going live: the checklist before real visitors use the form.
- In the app: Pipelines and stages.
Related pages
Link to this section: Related pagesTutorials
Sync contacts from your CRM
Mirror the people in your CRM as Filmo contacts and keep duplicates in check, since the API cannot rule them out: match by email or by the contact number you set, keep your own table of ids, and ignore the echo of your own writes.
Guides
Idempotency and safe retries
Send an
Idempotency-Keyheader with every write and reuse the same value when you retry that operation: Filmo returns the stored result of the first attempt instead of writing twice.Guides
Errors
Errors are RFC 9457 problem documents. Branch on the stable
code, never on the text, and use the retry table to decide whether to try again.Guides
Rate limits
Each API key has 120 requests per minute, and the three availability checks share their own quota of 30 per minute. Past a limit the API answers 429 with
Retry-After.Tutorials
From availability check to reserved order
Check stock from your server with a cache and batches that respect the 30 requests per minute of the availability operations, create the order with an idempotency key, then reserve it and handle
insufficient_stock.
Was this page helpful?