Skip to main content

API

Integrations

API

This documentation covers the RESTful API endpoints for managing customers in the system. All endpoints are protected by ApiClientMiddleware and require proper authentication.

Create a secret key

To get started, create a secret key to authenticate your API requests.

Use https://api.truebeep.com/v1 as the base URL for all TrueBeep API requests.

POST/customer

Create Customer

Creates a new customer record.

Example Responses:
{
"success": true,
"data": {
"id": "e8c43l37t1zayx8t66t5xbg9",
"firstName": "John",
"lastName": "Doe",
"phone": "+177566302",
"points": 15
}
}
Parameters:
FieldTypeDescription
firstName(Required)
stringThis field is required
lastName(Required)
stringThis field is required
phone(Conditional)
stringMin 10 digits
email(Conditional)
stringValid email
username(Optional)
stringOptional
info

Conditional fields indicate that at least one of the marked fields is required. For this endpoint, you must provide either phone or email (or both) along with the required fields.

POST/customers

Bulk Create Customers

Create multiple customer records in a single request.

Example Responses:
[
{
"id": "uuid",
"firstName": "John",
"lastName": "Doe",
"phone": "1234567890",
"email": "john.doe@example.com"
},
{
"id": "uuid2",
"firstName": "Sam",
"lastName": "Smith",
"phone": "9876543210",
"email": "sam.smith@example.com"
}
]
Parameters:
FieldTypeDescription
firstName(Required)
stringThis field is required
lastName(Required)
stringThis field is required
phone(Conditional)
stringMin 10 digits
email(Conditional)
stringValid email
username(Optional)
stringOptional
info

Conditional fields indicate that at least one of the marked fields is required. For this endpoint, you must provide either phone or email (or both) along with the required fields.

PUT/customer/:customerId

Update Customer

Update customer information.

Example Responses:
{
"id": "uuid",
"firstName": "Walter",
"lastName": "White",
"phone": "+1234567890"
}
GET/customer/:customerId

Get Customer

Retrieve customer information by ID.

Example Responses:
{
"id": "uuid",
"firstName": "John",
"lastName": "Doe",
"phone": "1234567890",
"email": "john.doe@example.com",
"points": "100"
}
POST/customer/:customerId/qr

Update Customer QR Points

Update customer points using a QR code scan.

Example Responses:
{
"success": true,
"data": {
"id": "jv3cstlguhr2e91oihi0bfsx",
"firstName": "John",
"lastName": "Doe",
"points": 2
}
}
Parameters:
FieldTypeDescription
code(Required)
stringThis field is required
couponId(Required)
stringThis field is required
POST/customer/:customerId/loyalty

Update Customer Loyalty Points

Update a customer's loyalty points.

Example Responses:
{
"id": "l7817f8lbdqn18pok75x2mnm",
"firstName": "John",
"lastName": "Doe",
"email": "john@gmail.com",
"points": 10
}
Parameters:
FieldTypeDescription
points(Required)
numberThis field is required
type(Required)
increment or decrementThis field is required

Forms API

The Forms API lets your own website, app, or backend submit responses into a TrueBeep form. You keep your existing front-end — the markup, styling, and validation stay yours — and TrueBeep becomes the place the submissions are stored, searched, exported, and turned into customer records.

Use it to:

  • Replace a mailto or a bespoke form handler with a real inbox you can filter and export
  • Consolidate many website forms into one TrueBeep form, tagging each submission with the page it came from
  • Capture leads as customers automatically — a submission with an email or phone is matched to an existing customer or creates a new one

All Forms endpoints are protected by ApiClientMiddleware and use the same secret key as the rest of the API. Base URL https://api.truebeep.com/v1, with your key as a bearer token:

Authorization: Bearer <token>

Requests are scoped to the team that owns the key — you can only read and write your own team's forms.

warning

Server-to-server only. Your secret key grants full write access to your team's data, so it must never be embedded in browser JavaScript or a mobile app binary. Post the form to your own backend first, then have your backend call this API.

How it works

1

Create the form

Build it in the Form Builder, or create it over the API with POST /forms. Either way the form defines the schema that submissions are validated against.

2

Read the field keys

Call the schema endpoint to get each field's key. Keys are what you submit against.

3

Submit responses

POST an array of key / value pairs. The response appears in your dashboard under Forms → View Responses immediately.

See the Forms guide for building and publishing a form.

About field keys

Every field has a key — a lowercase, underscore-separated identifier derived from its label. First Name becomes first_name, Email Address becomes email_address.

danger

Keys are regenerated from the label every time the form is saved in the builder. Renaming a field label will change its key and silently break an integration that hard-codes the old one. Two further quirks to be aware of when naming fields:

  • A question mark becomes a trailing qHow did you find us? yields how_did_you_find_usq
  • Digits are replaced with letters derived from the field's internal id, so Line 2 yields an unpredictable key

Prefer labels without digits or question marks, and always read keys from the schema endpoint rather than guessing them.

POST/forms

Create a Form

Creates a form and all its fields in one request. Returns the generated formId and each field's server-generated key.

Example Responses:
{
"success": true,
"data": {
"id": "q06e8ni8x0oksl2l23xxknjz",
"name": "Contact Us",
"status": "published",
"fields": [
{ "key": "first_name", "label": "First Name", "type": "varchar", "fieldType": "TextInput", "isRequired": true, "options": [] },
{ "key": "email_address", "label": "Email Address", "type": "varchar", "fieldType": "EmailInput", "isRequired": true, "options": [] },
{ "key": "region", "label": "Region", "type": "varchar", "fieldType": "Select", "isRequired": false, "options": ["Guam", "CNMI"] }
]
}
}
Body Parameters:
FieldTypeDescription
name(Required)
stringMust be unique within your team
fields(Required)
arrayAt least one field — see the field object below
description(Optional)
stringShown under the form title
formPurpose(Optional)
stringContext used by AI form filling
status(Optional)
stringdraft or published. Default draft
isPublic(Optional)
booleanDefault true
createCustomerOnSubmit(Optional)
booleanDefault true
webhookUrl(Optional)
stringPOSTed the full response after each submission
webhookSecret(Optional)
stringSent as Authorization: Bearer on the webhook
Field object:
FieldTypeDescription
fieldType(Required)
stringTextInput, Textarea, EmailInput, PhoneInput, NumberInput, DatePicker, Select, Radio, Checkbox
label(Required)
stringAlso the source of the generated key
isRequired(Optional)
booleanDefault false
options(Optional)
string[]Required for Select, Radio, and checkbox groups
isGroup(Optional)
booleanCheckbox only — renders a multi-select group
placeholder(Optional)
string
helperText(Optional)
stringHint shown under the field
defaultValue(Optional)
string
aiInstruction(Optional)
stringGuidance for AI-assisted form filling
warning

You cannot choose the field keys. They are generated from each label using the same rules the Form Builder uses, so that opening the form in the builder and saving it does not rename them. Read the keys from the create response, or from GET /forms/:formId/schema, before you write any code that submits to the form.

info

Field ordering follows the order of the fields array. type (the storage type) is derived from fieldType — you do not send it.

PATCH/forms/:formId

Update a Form

Updates a form's settings and, optionally, its fields. Every property is optional — send only what you want to change.

Example Responses:
{
"success": true,
"data": {
"id": "q06e8ni8x0oksl2l23xxknjz",
"name": "Contact Us",
"status": "published",
"fields": [
{ "key": "first_name", "label": "First Name", "type": "varchar", "fieldType": "TextInput", "isRequired": true, "options": [] }
],
"removedFields": []
}
}

Accepts every body parameter from Create (except that name is optional), plus:

FieldTypeDescription
fields(Optional)
arrayOmit to leave fields untouched. If sent, this is the complete list the form ends up with
removeMissingFields(Optional)
booleanConfirms deletion of fields absent from fields. Default false

How fields are matched

Fields are matched to the existing ones by label. A field whose label is unchanged keeps its id and key, so every answer already collected against it is preserved while its type, options, or required flag change freely. A field with a new label is created; renaming a label therefore creates a new field rather than renaming the old one.

danger

Removing a field permanently deletes every answer already collected for it, across all past responses — it cannot be undone.

Because fields is a complete replacement list, simply forgetting to include an existing field would silently destroy data. The API therefore refuses the update, listing the fields at risk.

Re-send the missing fields to keep them, or set removeMissingFields: true to confirm you intend to delete them and their data.

{
"success": false,
"code": "FIELDS_WOULD_BE_REMOVED",
"message": "These fields are missing from the update and removing them deletes every answer already collected for them. Re-send them, or set removeMissingFields to true to confirm.",
"fields": ["how_did_you_find_us"]
}
info

Header, footer, and styling configured in the Form Builder are preserved on update — only the field list is rewritten.

GET/forms

List Forms

Returns every form belonging to your team, so you can find the formId you need without opening the dashboard.

Example Responses:
{
"success": true,
"data": [
{
"id": "qo0zhyldtdiv7vxgtg6emq5r",
"name": "Contact Us",
"status": "published",
"provider": "internal",
"isPublic": true,
"responseCount": 128,
"createdAt": "2026-08-20T01:37:41.438Z",
"updatedAt": "2026-08-20T01:37:41.438Z"
}
]
}
GET/forms/:formId/schema

Get Form Schema

Returns the form's fields, in the order they appear in the builder. Call this once when building your integration — and again whenever someone edits the form — to pick up the current keys and select options.

Example Responses:
{
"success": true,
"data": {
"id": "qo0zhyldtdiv7vxgtg6emq5r",
"name": "Contact Us",
"status": "published",
"fields": [
{
"key": "first_name",
"label": "First Name",
"type": "varchar",
"fieldType": "TextInput",
"isRequired": true,
"options": [],
"maxLength": 255
},
{
"key": "who_is_this_directed_to",
"label": "Who is this directed to",
"type": "varchar",
"fieldType": "Select",
"isRequired": false,
"options": ["Customer Service / Tech Support", "Business Sales", "Ad Sales"],
"maxLength": 255
},
{
"key": "comment",
"label": "Comment",
"type": "text",
"fieldType": "Textarea",
"isRequired": false,
"options": [],
"maxLength": null
}
]
}
}
Field object:
FieldTypeDescription
keystringThe identifier you submit against
labelstringHuman-readable label as shown on the form
typestringStorage type — varchar, text, number, boolean, or date
fieldTypestringBuilder component — TextInput, Select, Checkbox, Textarea, ...
isRequiredbooleanSubmissions missing this field are rejected
optionsstring[]Allowed values for Select, Radio, and checkbox groups. Empty otherwise
maxLengthnumber255 for varchar fields, null when unlimited
POST/forms/:formId/responses

Submit a Form Response

Creates a form response. Returns the new response id and, when a customer could be matched or created, the customer id.

Example Responses:
{
"success": true,
"data": {
"formResponseId": "e8c43l37t1zayx8t66t5xbg9",
"subscriberId": "jv3cstlguhr2e91oihi0bfsx"
}
}
Body Parameters:
FieldTypeDescription
responses(Required)
arrayAt least one { key, value } pair
responses[].key(Required)
stringA key from the schema endpoint
responses[].value(Required)
mixedString, number, boolean, or array of strings — see the table below
customer(Optional)
objectOverrides the customer details derived from the submission
customer.firstName(Optional)
stringOptional
customer.lastName(Optional)
stringOptional
customer.email(Optional)
stringValid email
customer.phone(Optional)
stringOptional

Value formats by field type

The type returned by the schema endpoint determines what a value may be:

typeAccepted valueNotes
varcharstring, number, or string[]Max 255 characters. For Select / Radio the value must be one of options. Checkbox groups take an array and are stored pipe-joined
textstringNo length limit — use this for long free-text and URLs
numbernumber or a numeric stringMust be finite
booleantrue / false, or "true" / "false"
dateISO 8601 stringe.g. 2026-08-20T00:00:00.000Z
warning

varchar fields are capped at 255 characters and a longer value is rejected with INVALID_FIELD_VALUES. If you need to send something long — a page URL with tracking parameters, a plan summary, a free-text comment — make that field a Textarea in the builder so it is stored as text instead.

Validation rules

  • Required fields are enforced. Any field with isRequired: true that is missing, null, or blank rejects the whole submission with 400 MISSING_REQUIRED_FIELDS.
  • Unknown keys are ignored, not rejected. A key that does not exist on the form is dropped and echoed back in data.ignoredKeys so typos surface without failing live traffic. Always check this array while building your integration.
  • Blank optional fields are skipped. They are not stored as empty values.
  • Select and Radio values are checked against options, so a value that does not match is rejected rather than silently stored.
{
"success": true,
"data": {
"formResponseId": "e8c43l37t1zayx8t66t5xbg9",
"subscriberId": null,
"ignoredKeys": ["emial_address"]
}
}
GET/forms/:formId/responses

List Form Responses

Returns a form's responses, newest first, with pagination and filtering.

Example Responses:
{
"success": true,
"data": [
{
"id": "kt36fnwwr0ni3dk6biksw8nf",
"createdAt": "2026-08-20T02:40:06.967Z",
"source": "public",
"isRead": false,
"formId": "qo0zhyldtdiv7vxgtg6emq5r",
"formName": "Contact Us",
"subscriberId": "v863ihow50ztrh8kserqhvjx",
"subscriberFirstName": "John",
"subscriberLastName": "Doe",
"fields": [
{ "key": "first_name", "label": "First Name", "type": "varchar", "fieldType": "TextInput", "value": "John" },
{ "key": "inquiry_source", "label": "Inquiry Source", "type": "varchar", "fieldType": "Select", "value": "fiber" },
{ "key": "comment", "label": "Comment", "type": "text", "fieldType": "Textarea", "value": "Please call me." }
]
}
],
"pagination": { "page": 1, "limit": 20, "total": 137, "totalPages": 7 }
}
Query Parameters:
FieldTypeDescription
page(Optional)
numberDefault 1
limit(Optional)
numberDefault 20, max 100
order(Optional)
stringasc or desc by submission time. Default desc
source(Optional)
stringpublic (form or API) or ai (collected by AI auto-reply)
isRead(Optional)
stringtrue or false
startDate(Optional)
stringISO date; filters createdAt >=
endDate(Optional)
stringISO date; filters createdAt <=
search(Optional)
stringCase-insensitive match across every answer on the response
fieldKey(Optional)
stringFilter on one field, e.g. inquiry_source. Requires fieldValue
fieldValue(Optional)
stringExact value the field must equal. Requires fieldKey
tip

fieldKey + fieldValue is what makes one shared form workable across many pages. If every page sets its own inquiry_source, then ?fieldKey=inquiry_source&fieldValue=fiber gives you just that page's submissions — no separate form needed per page.

info

fields always lists every field currently on the form, in builder order. A field the responder left blank — or one added to the form after the response was collected — comes back with value: null, so the shape is stable across rows and easy to turn into table columns or CSV.

GET/forms/:formId/responses/:responseId

Get a Form Response

Returns a single response in the same shape as a list entry. Responds 404 RESPONSE_NOT_FOUND if the response does not exist, is not on that form, or belongs to another team.

Customer matching

When a submission contains an email or phone number, TrueBeep looks for an existing customer on your team with that channel and links the response to them, creating a new customer if there is no match. The returned subscriberId is that customer.

Values are taken from the submission automatically:

  • Email — the first field with fieldType: "EmailInput"
  • Phone — the first field with fieldType: "PhoneInput"
  • Name — the first_name and last_name fields, or a single name field split on the first space

Send a customer object to override any of these. If a submission has neither an email nor a phone, subscriberId is null and the response is still stored.

info

Customer matching never fails a submission. If the customer record cannot be created, the form response is still saved and subscriberId comes back null.

Turning customer creation off

Customer creation is controlled by the form, not by the request. Open the form in the Form Builder → Settings → Customer and switch Create Customer On Submit off to collect responses without adding anyone to your customer list.

It is on by default, and the setting applies to every route into the form — this API and the public form page alike — so all your pages behave consistently and no integration can opt itself out.

The current value is returned by the schema endpoint:

{
"success": true,
"data": {
"id": "qo0zhyldtdiv7vxgtg6emq5r",
"name": "Contact Us",
"createCustomerOnSubmit": true,
"fields": []
}
}

With it off, submissions still succeed and every answer is still stored — including name, email and phone, which remain visible in the responses table and in exports. You simply get subscriberId: null and nobody is added to your customer list.

warning

Switching it off is not retroactive, and there is no way to back-fill customers from responses collected while it was off. If you are unsure, leave it on — you can always turn it off later, but you cannot recover the gap.

Consolidating several website forms into one

If the same enquiry form appears on many pages, you do not need a TrueBeep form for each one. Build a single form with the union of the fields, mark the page-specific ones optional, and add a field that records where the submission came from — a Select of page slugs, plus a Textarea for the full URL.

Every page then posts to the same formId, differing only in that source value, and you get one inbox and one export you can still segment by page.

{
"responses": [
{ "key": "first_name", "value": "John" },
{ "key": "email_address", "value": "john.doe@example.com" },
{ "key": "inquiry_source", "value": "fiber" },
{ "key": "source_url", "value": "https://www.example.com/shop/internet/fiber?utm_source=newsletter" }
]
}
tip

Make the source field a Select so its values stay consistent and filterable, and keep the full-URL field a Textarea — URLs with tracking parameters routinely exceed the 255-character varchar limit.

Webhooks

If the form has a webhook URL configured in its settings, TrueBeep POSTs the complete response — including every field's key, label, type, and value — to that URL after a successful submission. When a webhook secret is set it is sent as Authorization: Bearer <secret>.

The webhook target always comes from the form's own settings and can never be supplied in the request body. Delivery is fire-and-forget with a 10-second timeout: a failing webhook is logged but does not fail the submission.

Errors

codeStatusMeaning
FORM_NOT_FOUND404No form with that id on your team
RESPONSE_NOT_FOUND404No such response on that form
FORM_NAME_TAKEN409Another form on your team already uses that name
FIELDS_WOULD_BE_REMOVED409An update omitted existing fields; confirm with removeMissingFields
NO_FORM_OWNER422The team has no existing form to inherit an owner from — create one in the dashboard first
FORM_HAS_NO_FIELDS422The form exists but has no fields yet — finish it in the builder
MISSING_REQUIRED_FIELDS400One or more required fields were absent. The offending keys are in fields
INVALID_FIELD_VALUES400A value did not match its field's type, options, or length. Per-field messages are in fields
AUTH_HEADER_MISSING401No Authorization header
INVALID_CLIENT401The key does not resolve to a team
API_KEY_EXPIRED401Generate a new key in team settings
{
"success": false,
"code": "MISSING_REQUIRED_FIELDS",
"message": "Missing required field(s): first_name, email_address",
"fields": ["first_name", "email_address"]
}

Analytics API

The Analytics API exposes the social analytics behind your TrueBeep dashboard — post-level metrics and page/profile-level insights — for every connected platform: Facebook, Instagram, X (Twitter), LinkedIn, TikTok, and YouTube.

All endpoints are protected by ApiClientMiddleware and require the same secret key as the rest of the API. Use https://api.truebeep.com/v1 as the base URL, and pass your key as a bearer token:

Authorization: Bearer <token>

Requests are automatically scoped to the team that owns the API key — you only ever see your own team's analytics.

Response shape: normalized + raw

Every metrics payload carries two views so you never have to relearn each platform's vocabulary, yet never lose detail:

  • metrics / kpis — a normalized common set (impressions, reach, engagement, likes, comments, shares, clicks, videoViews, engagementRate). A metric that a platform does not report is null (distinct from a measured 0).
  • rawMetrics / rawKpis — the platform's original keys, untouched (e.g. Facebook's post_video_views_15s, LinkedIn's uniqueImpressionsCount).
info

null means "this platform does not provide this metric" or "not yet ingested" — it is not the same as 0. Data is only as fresh as the last analytics refresh; page/profile responses include lastRefreshedAt so you can surface staleness.

GET/analytics/social/{platform}/posts

List Post Analytics

Returns paginated post-level analytics for a single platform. Post sub-types (feed post, reel, story) are mixed together by default and can be narrowed with postType.

Example Responses:
{
"success": true,
"data": [
{
"id": "e8c43l37t1zayx8t66t5xbg9",
"postId": "1224...901",
"platform": "facebook",
"postType": "post",
"campaignId": null,
"content": "Summer sale is live!",
"permalinkUrl": "https://facebook.com/...",
"publishedAt": "2026-06-10T12:00:00.000Z",
"metrics": {
"impressions": 1200,
"reach": 900,
"engagement": 84,
"likes": 40,
"comments": 8,
"shares": 6,
"clicks": 30,
"videoViews": 210,
"engagementRate": 0.07
},
"rawMetrics": {
"post_impressions": 1200,
"post_impressions_unique": 900,
"post_video_views_15s": 150
}
}
],
"pagination": { "page": 1, "limit": 20, "total": 137 }
}
Path Parameters:
FieldTypeDescription
platform(Required)
stringfacebook, instagram, twitter, linkedin, tiktok, or youtube
Query Parameters:
FieldTypeDescription
postType(Optional)
stringpost, reel, or story. Omit for all. A no-op on platforms without sub-types (e.g. Twitter)
startDate(Optional)
stringISO date; filters publishedAt >=
endDate(Optional)
stringISO date; filters publishedAt <=
campaignId(Optional)
stringFilter to a single campaign
search(Optional)
stringCase-insensitive match on post content
page(Optional)
numberDefault 1
limit(Optional)
numberDefault 20, max 100
order(Optional)
stringasc or desc by publishedAt. Default desc
info

startDate / endDate accept full ISO timestamps (2026-06-30T23:59:59Z) or plain dates (2026-06-30).

GET/analytics/social/{platform}/posts/:id

Get a Single Post

Returns the analytics for one post. Responds 404 if the post is not in your team or does not belong to {platform}. The data object has the same shape as a list element above.

{
"success": true,
"data": {
"id": "e8c43l37t1zayx8t66t5xbg9",
"postId": "1224...901",
"platform": "facebook",
"postType": "post",
"metrics": { "impressions": 1200, "engagement": 84, "engagementRate": 0.07 },
"rawMetrics": { "post_impressions": 1200 }
}
}
GET/analytics/social/{platform}/profile

Profile / Page Analytics

Returns page/profile-level insights for a platform — headline KPIs, a time-series for the selected window, and period-over-period percentage change.

Example Responses:
{
"success": true,
"data": {
"platform": "instagram",
"status": "ready",
"connected": true,
"lastRefreshedAt": "2026-07-13T04:00:00.000Z",
"window": "30d",
"kpis": {
"followers": 5400,
"impressions": 88000,
"reach": 61000,
"engagement": 4300
},
"rawKpis": { "Followers": 5400, "Reach": 61000 },
"timeSeries": { "impressions": [], "reach": [] },
"percentageChange": { "impressions": 0.12, "reach": -0.03 }
}
}
Query Parameters:
FieldTypeDescription
window(Optional)
string7d, 30d, 90d, or 12m. Default 30d
info

Profile metrics are stored as pre-aggregated windows (7d / 30d / 90d / 12m), so — unlike posts — they do not accept arbitrary date ranges. Pick the closest window.

GET/analytics/social/summary

Cross-Platform Summary

Returns one entry per platform (including ones you have not connected), ideal for a dashboard landing view in a single call.

Example Responses:
{
"success": true,
"data": {
"window": "30d",
"platforms": [
{
"platform": "facebook",
"status": "ready",
"connected": true,
"lastRefreshedAt": "2026-07-13T04:00:00.000Z",
"kpis": { "followers": 12000, "impressions": 240000, "reach": 180000, "engagement": 9100 },
"postCount": 42
},
{
"platform": "tiktok",
"status": "not_connected",
"connected": false,
"lastRefreshedAt": null,
"kpis": null,
"postCount": 0
}
]
}
}
Query Parameters:
FieldTypeDescription
window(Optional)
string7d, 30d, 90d, or 12m. Default 30d
platforms(Optional)
stringComma-separated filter, e.g. facebook,instagram. Omit for all

Connection status

Because "not connected" is a normal state, profile and summary responses carry an explicit status instead of erroring:

statusconnectedMeaning
not_connectedfalseThe platform is supported but your team hasn't connected it
pendingtrueConnected, but analytics haven't been ingested yet
unavailabletrueConnected, but analytics aren't available (e.g. a LinkedIn personal profile)
readytrueConnected and data is present
info

An unsupported platform in the path (e.g. /analytics/social/myspace/posts) returns 404 PLATFORM_NOT_SUPPORTED. A supported-but-not-connected platform returns 200 with status: "not_connected".

Metric availability by platform

Not every platform reports every metric — unavailable ones are null in metrics. Notable differences:

PlatformNotes
FacebookFull post + page metrics; reach is unique impressions
Instagramreach, total_interactions (→ engagement), saves in raw
Twitter/XUses public_metrics; reach/clicks not reported
LinkedInMetrics nested per post; engagementRate is a stored ratio; personal profiles have none
TikTokview_count drives impressions/reach/videoViews; no clicks
YouTubeViews drive impressions; channel KPIs come from account metadata