# VisitorLoop
> VisitorLoop: Popups, email and live chat that turn website visitors into customers. Website widgets (popups, banners, messages, social proof, tooltips, embedded forms and live chat) from one script tag; the contacts they collect; and emails that follow up.
Docs: https://visitorloop.app/docs. The same as JSON: GET https://visitorloop.app/api/v1.
## Add it to a website
```html
```
Once per page, in the
(or before ) of every page widgets should appear on. SITE_KEY is the site's key (public, safe in a page): Sites in the dashboard, or `you.sites` here with a key.
The script exposes `window.VisitorLoop`:
- `VisitorLoop.identify({ id, email, name, ...traits })`: After a login: who the visitor is. Live chat shows them, and their later form entries link to them. Traits are strings, numbers or booleans. Sent once per browser session for the same person, so calling it on every page is fine.
- `VisitorLoop.reset()`: After a logout: the browser becomes a new, anonymous visitor, so the next person on it isn't linked to the one before.
- `VisitorLoop.setFormData({ field: "value" }) or setFormData("widgetId", { … })`: Fills form fields by name (hidden ones too) in every widget, or one: a user id, a plan, a page's product.
- `VisitorLoop.track("widgetId", "CONVERSION")`: Credits a widget with a conversion that happened outside its own form (a purchase, a sign-up). Types: CONVERSION, CLICK, IMPRESSION, CLOSE.
- `VisitorLoop.reinit()`: Looks at the page again. Single-page apps are followed automatically (history changes); call it after a router swaps the whole page in a way that doesn't change the URL.
- `VisitorLoop.listWidgets()`: The widgets on this page, with their form fields: for finding ids and field names in the browser console.
On the site, window.VisitorLoop exists, and the request to https://visitorloop.app/api/v1/public/sites//config?visitorId=… answers 200 (a 403 "Origin not allowed" means the page's domain isn't one of the site's allowed domains; the console then says "[VisitorLoop] Failed to load config"). Widgets show once they're published in the dashboard.
## API
Base URL https://visitorloop.app/api/v1. Send `Authorization: Bearer `. Made in https://visitorloop.app/app/api-keys (shown once), or with the "Hand it to your AI agent" prompt. Server-side only: never put a key in a web page or an app.
Key scopes:
- `contacts`: Adds and updates contacts, looks one up by email, and reads widgets and email templates. It can't list your contacts or send emails.
- `full`: Everything: contacts (read and write), widgets, templates, and sending emails (trigger, broadcasts).
JSON in and out. Errors: { error, issues? } with a 4xx status (401 no or bad key, 403 the key's scope doesn't allow it, 400 invalid input, 404 not found).
### GET /api/v1
This manifest. With a key, `you` adds the workspace: its sites (with their site keys), widgets and the key's scope.
Key: none needed.
Answers: { name, version, baseUrl, authentication, endpoints, browserSdk, webhooks, you? }
### POST /api/v1/leads
Add a contact (a lead), or update the one with the same email: `data` is merged into what's there. Safe to call on every sign-up or login. When someone changes their email, send the new one with `previousEmail`. Without `widgetId`, API contacts are filed under "Contacts from your app", a widget made for them (archived: it never shows on a site).
Key: contacts or full.
Body:
- `email`: string (email or phone is required)
- `phone`: string
- `data`: object of strings, numbers or booleans: anything you know (name, plan, company, …). Emails use it as {{key}} (or {{lead.data.key}}); `name` is also {{name}}
- `widgetId`: string, optional: the widget it came from (GET /api/v1/widgets)
- `previousEmail`: string, optional: the email they had before, when it changed (that contact gets the new one)
- `starred`: boolean, optional
```json
{ "email": "jane@acme.com", "data": { "name": "Jane Doe", "plan": "pro", "signed_up": "2026-10-18" } }
```
Answers: 201 { lead, updated: false } when added, 200 { lead, updated: true } when an existing contact was updated
### GET /api/v1/leads?email=…
The one contact with that email, or null: to check that a sign-up arrived, or what's known about someone.
Key: contacts or full.
Answers: { lead: { id, email, phone, data, starred, unsubscribed, widgetId, createdAt } | null }
### GET /api/v1/leads
The contacts, newest first.
Key: full.
Query:
- `limit`: 1–200 (default 50)
- `cursor`: nextCursor from the page before
Answers: { leads: [{ id, email, phone, data, starred, unsubscribed, widgetId, createdAt }], nextCursor }
### GET /api/v1/widgets
The widgets (popups, banners, messages, social proof, tooltips, embedded forms).
Key: contacts or full.
Answers: { widgets: [{ id, name, channel, status, siteId, createdAt }] }
### GET /api/v1/templates
The email templates, for POST /api/v1/trigger.
Key: contacts or full.
Answers: { templates: [{ id, name, subject, createdAt, updatedAt }] }
### POST /api/v1/trigger
Email a template to every subscribed contact whose `data` has all the `match` values (for example everyone who bought a product).
Key: full.
Body:
- `match`: object, at least one key: { "product_id": "abc123" } matches contacts with data.product_id = "abc123"
- `template_id`: string: an email template (GET /api/v1/templates)
- `vars`: object, optional: extra template variables for this send
```json
{ "match": { "product_id": "abc123" }, "template_id": "tpl_…", "vars": { "product_name": "Trail shoes" } }
```
Answers: { ok, matched, sent, failed }
### POST /api/v1/broadcasts/{token}/send
Send a saved broadcast now (its trigger token is on the broadcast's page in the dashboard). Each call sends a copy to its audience. Send an `Idempotency-Key` header so a retry doesn't send twice.
Key: full.
Headers:
- `Idempotency-Key`: optional, a new UUID per send: a repeat within 24 hours replays the first answer
Body:
- ``: optional: a template variable for this send
- `filter`: optional: { "productId": "…" } narrows to contacts whose data matches; { "skipIfSentWithin": "1h" } skips contacts sent this broadcast within that window (30s, 10m, 1h, 1d)
Answers: { ok, broadcastId, status, recipientCount, sentCount, failedCount, failureReason }: status "SENT" (or "FAILED") when it finished within 20 seconds, "SENDING" when the rest carries on in the background (the dashboard shows its progress; past today's email limit it goes on the next day)
## Webhooks
https://visitorloop.app/app/webhooks: a URL, the events, and a secret.
- `lead.created`: Someone filled in a widget's form, or a contact was added through the API
POST, JSON: { "event": "lead.created", "data": { …the contact }, "timestamp": "ISO 8601" }
- `X-VisitorLoop-Event`: the event
- `X-VisitorLoop-Signature`: sha256=
Compute the HMAC over the raw request body (before parsing it) and compare in constant time; answer 2xx within 10 seconds.