Skip to main content

Widget Submissions

Submission endpoint

When a visitor submits a widget form, the widget loader POSTs to:
POST https://us-central1-{PROJECT_ID}.cloudfunctions.net/submitModularWidget
This is an HTTP function with CORS enabled. It is publicly accessible — no authentication is required from the visitor.

Accepted input fields

The following fields can be collected by widget input blocks. All are optional unless marked as required in the widget’s block configuration.
FieldTypeNotes
namestringFull name or display name
emailstringUsed for contact deduplication
phonestringPhone number (normalized server-side)
companystringCompany or organisation name
jobTitlestringJob title
messagestringFree-text message field
notesstringAdditional notes
_hpstringHoneypot field — must be empty. Always include in your form but keep it hidden.
Additional custom fields from text, textarea, select, checkbox, and date blocks are collected as key-value pairs and stored in the lead’s form data.

Server-side processing flow

Incoming POST

    ├─ 1. Rate limit check (IP + org)
    │        → Exceeded: return 200 (silent drop)

    ├─ 2. Honeypot check (_hp field)
    │        → Non-empty: return 200 (silent drop)

    ├─ 3. Duplicate submission check (60s window, same IP + org)
    │        → Duplicate: return 200 (silent drop)

    ├─ 4. Fetch published widget version
    │        → Not found / not published: return 400

    ├─ 5. Validate + normalize data
    │        → Trim strings, standardize phone format

    ├─ 6. Contact upsert
    │        → Check for existing contact by email in org
    │        → If found: update with any new data
    │        → If not found: create new contact

    ├─ 7. Lead creation
    │        → Create lead record linked to contact
    │        → Source: "widget", widgetId, widgetType

    ├─ 8. Usage event recorded (WIDGET_FORM_SUBMIT)

    ├─ 9. Auto-proposal (if enabled in org AI settings)
    │        → Triggers proposal generation asynchronously

    └─ 10. Webhook dispatch
             → POST to all configured webhook endpoints for this org
             → Events: widget.submitted

Return: HTTP 200 { success: true, message: "Form submitted successfully" }
The endpoint always returns HTTP 200, including for silently dropped submissions (spam, honeypot, duplicates). This is intentional — it prevents attackers from probing the spam filters.

Response

{
  "success": true,
  "message": "Form submitted successfully"
}

Contact deduplication

On every submission, Financely looks up an existing contact in the organization by exact email match (case-insensitive). If found, the contact record is updated with any new non-empty fields from the submission. If not found, a new contact is created. This means repeated submissions from the same email address enrich the contact record rather than creating duplicates.

Lead record

A lead record is created for every valid submission. Key fields:
{
  "organizationId": "org_abc123",
  "contactId": "contact_xyz789",
  "source": "widget",
  "widgetId": "widget_def456",
  "status": "new",
  "data": {
    "name": "Jane Smith",
    "email": "jane@example.com",
    "message": "I'd like a quote for 50 units."
  },
  "createdAt": "2025-03-15T10:30:00.000Z"
}

Webhook payload — widget.submitted

When a submission is processed, all configured webhook endpoints for the organization receive this payload:
{
  "event": "widget.submitted",
  "organizationId": "org_abc123",
  "widgetId": "widget_def456",
  "submittedAt": "2025-03-15T10:30:00.000Z",
  "data": {
    "name": "Jane Smith",
    "email": "jane@example.com",
    "phone": "+1 555 000 1234",
    "company": "Acme Corp",
    "message": "I'd like a quote for 50 units."
  },
  "leadId": "lead_ghi012"
}

HMAC signature verification (Node.js)

Every webhook request includes an X-Financely-Signature header. Verify it as follows:
const crypto = require('crypto');

function verifySignature(secret, rawBody, signatureHeader) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)          // rawBody must be the raw Buffer/string, not parsed JSON
    .digest('hex');

  const provided = signatureHeader.replace('sha256=', '');

  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(provided, 'hex')
  );
}

// Express example
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['x-financely-signature'];
  const isValid = verifySignature(process.env.WEBHOOK_SECRET, req.body, sig);

  if (!isValid) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body);
  // handle event...
  res.sendStatus(200);
});
Always use express.raw() (or equivalent) to get the raw body before parsing. Computing the HMAC on a re-serialized JSON object will not produce the same signature.