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.

  • 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.

ScopeUsed for
account:readThe first call: timezone, add-ons and the scopes of the key.
contacts:readFinding the contact by email.
contacts:writeCreating the contact.
crm:readListing pipelines and stages. Only needed if you send opportunities to a named pipeline or stage (optional step).
crm:writeCreating 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).

Call GET /api/v1/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.

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

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.

Call GET /api/v1/contacts with 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.

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"

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.

POST /api/v1/contacts 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"
}
JSON

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.

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 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"

POST /api/v1/crm/opportunities 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"
}
JSON

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 lead

A 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.

A 400 for the contact (abridged)
{
  "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 } };
}
ResponseWhat to do
429 rate_limitedWait 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_adminThe key's owner left or stopped being an administrator. Queue, alert and reissue the key.
402 addon_requiredThe account has no CRM add-on. Queue and alert; requiredAddon names it.
403 insufficient_scopeQueue and alert; requiredScope names the missing scope.
403 forbiddenThe owner of the key lacks the permission the operation requires. Queue and alert; fix it in the app.
409 conflictRead 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_reusedYou sent the same submission id with different content. A key identifies one operation; give changed content a new submission id.
500 internal_errorRepeat 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_unavailableNo 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.
WhatWhy
Submission idThe root of both keys and the identity of the lead in your logs.
Filmo contact id and opportunity idProof of what was created, for retries after the keys expired and for support.
requestId of any failed callWhat you quote to support.
The queued submissionsA lead that could not be sent yet. Replay them with the original submission id.
  • Tutorials

    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-Key header 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.