Integrations
Connect VisitorLoop to your stack using API keys, outbound webhooks, and the Resend bounce webhook.
API keys
API keys let external systems authenticate against the VisitorLoop REST API. Go to Settings → API Keys → New key. Give the key a name for your own reference.
The key is shown once — copy it immediately. Include it in API requests as:
Authorization: Bearer YOUR_API_KEYTreat API keys as secrets
Outbound webhooks
Webhooks let VisitorLoop notify your systems when events happen. Go to Settings → Webhooks → New webhook.
Supported events
- lead.created — fires every time a new lead is captured from a widget form.
Payload format
Payloads are sent as POST with Content-Type: application/json. A lead.created payload looks like:
{
"event": "lead.created",
"data": {
"id": "clxyz...",
"email": "[email protected]",
"phone": null,
"data": { "name": "Jane", "company": "Acme" },
"widget": { "id": "...", "name": "Homepage popup" },
"createdAt": "2026-04-15T10:30:00.000Z"
}
}Signature verification
Every webhook request is signed with HMAC-SHA256 using the secret shown when you create the webhook. Verify the signature in your endpoint to ensure the request is genuine:
import { createHmac } from "crypto";
function isValidSignature(body: string, signature: string, secret: string) {
const expected = createHmac("sha256", secret)
.update(body)
.digest("hex");
return signature === `sha256=${expected}`;
}
// In your endpoint:
const signature = request.headers["x-visitorloop-signature"] ?? "";
const rawBody = await request.text();
if (!isValidSignature(rawBody, signature, process.env.WEBHOOK_SECRET!)) {
return new Response("Forbidden", { status: 403 });
}Retries
If your endpoint returns a non-2xx status code, VisitorLoop will retry the delivery up to 3 times with exponential backoff. All delivery attempts (including failures) are recorded under the webhook in the dashboard.
Triggering broadcasts via API
Any saved broadcast can be re-fired from your own backend by hitting its trigger token. Each call clones the source broadcast (template + audience), sends the clone immediately, and returns delivery counts. The clone is linked to the source via triggeredById, so every fire shows up in the broadcast's History tab.
Find the trigger token on a broadcast's detail page in the dashboard. Tokens look like btr_M_V06f4PvAEze6cqfxjkKSOmGHmSmFu5.
Endpoint
POST https://visitorloop.app/api/v1/broadcasts/{token}/send
Authorization: Bearer YOUR_API_KEY
Content-Type: application/jsonBody (all fields optional)
- Any top-level scalar key becomes a per-send template variable. Use it to render announcement-level values like product name, CTA url, coupon code, etc. inside
{{...}}placeholders in the email template. filter(object) — narrows recipients by matching againstlead.data.*. Recipients must match the broadcast's saved audience AND every key infilter.- Scalar value — exact equality.
{ "productId": "abc" }→ leads withdata.productId === "abc". - Array value — match any (OR within the key).
{ "tag": ["vip", "beta"] }→ leads whosedata.tagequals any of those values. Useful for "send to anyone tagged X or Y". - Multiple top-level keys AND together (all must match).
- Scalar value — exact equality.
Example
Send a one-off announcement to the slice of your audience that matches a value stored on the lead. The body fields are arbitrary — name them whatever your template uses.
POST /api/v1/broadcasts/btr_yourTokenHere/send
{
"headline": "Your update is ready",
"ctaUrl": "https://example.com/details",
"filter": { "segment": "newsletter" }
}With this body the system selects leads whose data.segment === "newsletter" (intersected with the broadcast's saved audience) and passes headline and ctaUrl through to the template. Reference them with their key wrapped in {{ }}:
Subject: {{headline}}
Hi {{lead.name}},
{{headline}} — read the details here:
{{ctaUrl}}Combining API vars with per-lead values
Every recipient also has the variables built from their own lead record available in the same template — there's no "API vs. lead" split, they live in the same dictionary. So a single template can mix announcement-level values from the API call with personalised values from the lead.
The per-lead variables you can reference:
{{lead.name}}— derived fromlead.data.name,lead.data.firstName, or the lead's email; falls back to"there".{{lead.email}},{{lead.phone}},{{lead.id}}.{{lead.widget}}— the name of the widget that captured the lead.{{lead.data.X}}— any custom field stored on the lead, whereXis its field name (e.g.{{lead.data.productId}}).{{name}},{{email}}— convenience aliases forlead.nameandlead.email.
Filtering by lead data and personalising with it at the same time is the common case — e.g. target everyone in a segment, then address each by name and reference the value that put them in scope:
POST /api/v1/broadcasts/btr_yourTokenHere/send
{
"headline": "A new update for {{lead.data.segment}} members",
"ctaUrl": "https://example.com/details",
"filter": { "segment": "newsletter" }
}API vars are not interpolated
{{lead.data.segment}} inside them are not expanded. Put the per-lead placeholders in the template body, not in the API payload.A template that mixes both kinds of variables:
Subject: {{headline}}
Hi {{lead.name}},
You're in the {{lead.data.segment}} segment, and we just shipped something we
think you'll like.
{{headline}} — read the details here:
{{ctaUrl}}
Thanks,
The {{lead.widget}} teamVariable precedence
API-supplied variables override per-lead values when both define the same key — they represent the caller's deliberate per-send intent. If a lead happens to have a headline in its own data, the API value wins for this send. To avoid ambiguity, prefer {{lead.data.X}} (the fully-qualified form) when you specifically want the per-lead value.
Response
{
"ok": true,
"broadcastId": "cmclone...",
"recipientCount": 42,
"sentCount": 42,
"failedCount": 0,
"failureReason": null
}Filter caveats
contains, no substring/regex, and no support yet for filtering when the lead stored its own array (e.g. data.tags: ["a", "b"]). If you typo a key or pass a value no lead has, the call succeeds with recipientCount: 0. Outside of filter, nested objects and arrays in the body are ignored as template variables — only string-ifiable scalars are accepted there.Skip recently-sent leads
Some integrations fire the same trigger repeatedly in quick succession — e.g. a Shopify back-in-stock webhook firing every time inventory blips above zero. To stop the same lead getting the same broadcast twice in a few minutes, set filter.skipIfSentWithin to a duration. Leads who received this broadcast inside the window are excluded from the new send; everyone else gets it as normal.
POST /api/v1/broadcasts/btr_yourTokenHere/send
{
"productName": "Espresso machine",
"ctaUrl": "https://example.com/p/espresso",
"filter": {
"productId": "p_espresso",
"skipIfSentWithin": "10m"
}
}Accepted units: s, m, h, d — e.g. "30s", "10m", "1h", "24h", "1d". Malformed values return 400. Omit the field entirely → no dedupe.
- Dedupe is per source broadcast. All trigger fires of the same saved broadcast share one stream, so a follow-up call 5 minutes later sees the first call's recipients. Different broadcasts (different trigger tokens) track separately, so a stock alert won't suppress your weekly digest.
- The window is enforced against actual sends, not filter matches. If a lead was in the recipient set but bounced or unsubscribed before delivery, they aren't blocked from later sends.
- Combine freely with
Idempotency-Key— the two solve different problems.Idempotency-Keyreplays the same response for retries of one logical call;skipIfSentWithindedupes across different logical calls.
Idempotency
Network retries, double-clicks, and server-side retry loops can easily fire the same broadcast twice — sending the same email to every recipient a second time. To protect against this, pass a unique Idempotency-Key header per logical send. Generate a fresh UUID (or any stable string) on your end for each new send event; replays with the same key within 24 hours return the original response without re-sending.
POST /api/v1/broadcasts/btr_yourTokenHere/send
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
Idempotency-Key: 7f1a2b3c-4d5e-4a6b-9c7d-8e9f0a1b2c3d
{
"headline": "Your update is ready",
"ctaUrl": "https://example.com/details",
"filter": { "segment": "newsletter" }
}- First call with that key — sends as normal, caches the response.
- Replay with the same key within 24h — returns the cached response (same
broadcastId,sentCount, etc.) and adds anIdempotency-Replayed: trueresponse header. Nothing is sent. - Replay while the original is still in-flight — returns
409 ConflictwithRetry-After: 5so you can back off and try again. - New key with the same body — treated as a deliberate fresh send. Use a new UUID when you genuinely want to re-fire the same announcement (e.g. a corrected version).
No key = no protection
The loader script
The loader script (/loader.js) is the JavaScript snippet you embed on your website. It bootstraps the VisitorLoop SDK and polls the config API to decide which widgets to display.
Key behaviours:
- Fetches your published widgets from
/api/v1/public/configusing yoursiteKey. - Evaluates each widget's targeting and trigger conditions in the visitor's browser.
- Injects the widget UI into the DOM when conditions are met.
- Submits form data to
/api/v1/public/leadsto create a lead. - Initialises the chat widget and connects to
/api/v1/public/chat/*when a MESSAGE widget is active.
CORS and allowed domains
JavaScript API
Once the loader runs it exposes a small API on window.VisitorLoop. Use these methods to customise widget behaviour from your own code.
VisitorLoop.setFormData(values) — global
Pre-fills or overrides values for any form field by field name. This is the recommended way to populate hidden fields with per-visitor context (tenant ID, plan, referrer, logged-in user info), and works equally well for visible fields you want to pre-fill as defaults.
The single-argument form applies to every widget on the page that has a field with the matching name:
<script async src="https://visitorloop.app/loader.js" data-site="pk_..."></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
if (window.VisitorLoop) {
window.VisitorLoop.setFormData({
tenant_id: "acme-co", // overrides any hidden field named "tenant_id"
plan: "pro", // any custom hidden field
email: "[email protected]", // pre-fills any visible Email field
});
}
});
</script>VisitorLoop.setFormData(widgetId, values) — targeted
When you have multiple widgets on the same page (e.g. a popup and an embedded form that share a field name like email), pass the widget ID as the first argument to scope the values to that widget only.
window.VisitorLoop.setFormData("cmovhx30s0001prrwzfbifb6m", {
source: "newsletter-page",
email: "[email protected]",
});
// You can call it once per widget — values don't bleed across widgets.
window.VisitorLoop.setFormData("cmpopup1234", { campaign_tag: "spring-sale" });Targeted values override global values when both are set for the same field on the same widget.
Finding a widget's ID
/app/editor/cmovhx30s.... For a quick discovery from the browser console on a live page, run VisitorLoop.listWidgets() — it returns one object per registered widget, with itsid, name, channel, and form fields.VisitorLoop.listWidgets()
Returns metadata for every widget the loader has registered on the page — its ID, name, channel, variant, and form fields (name, type, label, required). Useful for discovering widget IDs and field names from devtools before wiring up setFormData.
VisitorLoop.listWidgets();
// → [
// {
// id: "cmovhx30s0001prrwzfbifb6m",
// name: "Fritel Out of Stock Embed",
// channel: "embed",
// variant: "form",
// fields: [
// { name: "email", type: "email", label: "Email", required: true },
// { name: "name", type: "text", label: "Name", required: false },
// { name: "product_id", type: "hidden", label: "Product id", required: false },
// ],
// },
// ]Sending arbitrary lead metadata without a hidden field
Keys passed to setFormData() that don't match any input in the form are still attached to the submitted lead's data JSON. This lets you tag leads with per-visitor context (campaign, source, product id, etc.) without declaring a hidden field for each one.
// No hidden field named "tag" needs to exist — it just lands in lead.data.tag.
window.VisitorLoop.setFormData("cmovhx30s0001prrwzfbifb6m", {
tag: "product1",
campaign: "winter-2026",
});Tag chips vs. lead.data
lead.data (free-form JSON) — visible when you open the lead's detail panel. They do not create coloured tag chips in the leads table; those use a separate Tag model assigned manually or via automations.How values resolve at submit time
- What the visitor typed in a visible input — always wins.
- Targeted value for that widget from
setFormData(widgetId, ...). - Global value from
setFormData(values). - Static value set on a hidden field in the widget editor.
Keys with no matching form input are merged into the submitted data after the inputs are collected — they never overwrite a value the visitor typed.
Match the field name exactly
name attribute of the field as configured in the editor — not the label. The field name is shown in each field's Advanced section in the editor. For example, a hidden field labelled Tenant ID with name tenant_id is set with { tenant_id: "..." }, not { "Tenant ID": "..." }.You can call setFormData() before or after the widget renders. Already-rendered inputs are patched in place, so it works fine inside DOMContentLoaded, after an async user fetch, or any time the visitor's identity becomes known.
VisitorLoop.identify(user)
Associates the current visitor with a known user record (e.g. after they log in). Pass an object with any combination of id, email, name, phone, and custom traits. Subsequent leads from this visitor will be linked to the same identified record.
window.VisitorLoop.identify({
id: "user_123",
email: "[email protected]",
name: "Jane Doe",
plan: "pro",
});It's sent once per browser session for the same person, so calling it on every page is fine.
VisitorLoop.reset()
Call it when someone logs out: the browser becomes a new, anonymous visitor, so the next person on a shared computer isn't linked to the one before.
VisitorLoop.track(widgetId, eventType)
Records an analytics event for a specific widget. Useful when you want to attribute a conversion that happens outside of the widget's own form. eventType is"CONVERSION" (the default), "CLICK", "IMPRESSION" or "CLOSE".