API WEBFUL v1

REST API to read your WEBFUL analytics data from any app, script or automation (n8n, Make, custom dashboards). GDPR-compliant, clean, versioned.

Base URL: https://webful.com/api/v1 GET only JSON

Technical content in English. Detailed endpoint descriptions, code samples and the playground are available in English only. This matches the industry standard (Stripe, Resend, OpenAI...) and avoids the risk of documentation drifting between two languages.

Live playground

See each endpoint in action

Demo page built entirely with the public API, on the demo@webful.fr account (read-only). Each stat shows the raw JSON and a code sample.

Open the playground

Introduction

The WEBFUL v1 API lets you read all the data visible in your dashboard, from your scripts, n8n/Make workflows, or internal interfaces. Read-only, with full coverage of the dashboard (account, sites, statistics, performance, SEO, geolocation, funnels, webhooks, agency view).

No mutations (create, update, delete) are exposed in v1: the API is read-only. Administrative operations remain in the web dashboard.

Never embed a key in a public frontend. CORS is open (Access-Control-Allow-Origin: *) to allow internal dashboards and server-side proxies, but any key exposed in JS accessible to visitors would be captured immediately. Call the API from your backend.

Conventions

  • Format: JSON (UTF-8). Only GET is exposed in v1.
  • Dates and timestamps: all full timestamps are in UTC, ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ, e.g. 2026-04-22T15:30:00Z). Date-only fields (e.g. current_period_end) stay in YYYY-MM-DD format.
  • Request ID: every response includes an X-Request-Id header (and meta.request_id / error.request_id). Pass it along when you report an issue.
  • Version: every response includes X-API-Version: v1.
  • Pagination: page (>=1) and per_page (1-200). The response includes page, per_page, total and total_pages. If page exceeds total_pages, the returned list is empty.
  • Compression: gzip supported in production. Send Accept-Encoding: gzip to enable it. Local development environments may not negotiate it depending on their Apache config.
  • Unknown parameters: query parameters not documented for an endpoint are silently ignored. The API only returns BAD_REQUEST for documented parameters whose value is outside the enum or allowed bounds.
  • Exposed CORS headers: frontend clients can read X-Request-Id, X-API-Version, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset and X-Data-Freshness (listed in Access-Control-Expose-Headers).
  • Data freshness: for endpoints that depend on a batch (PageSpeed, SEO analyzer), the response includes the X-Data-Freshness header with the ISO 8601 UTC timestamp of the most recent data available (e.g. X-Data-Freshness: 2026-04-22T03:14:00Z). Header absent = real-time data (inserted on each hit or aggregated at request time). Currently applies to /sites/{id}/performance and /sites/{id}/seo.
  • Cross-cutting meta fields: every response includes meta.generated_at (ISO 8601 UTC), meta.api_version ("v1"), meta.request_id and meta.rate_limit. Endpoints that accept from/to reflect the bounds in meta.period. Endpoints that accept a filter parameter (granularity, view, category, by, type, limit, max_depth, country_code, etc.) echo it in meta (effective value applied).
  • Data freshness: batch-dependent endpoints (PageSpeed, SEO) expose two meta fields: meta.data_source (e.g. "pagespeed_insights") and meta.refresh_cadence (open enum, observed values: "daily", "on_demand"). The X-Data-Freshness header is additionally returned for /performance (ISO timestamp of the last batch).

Quickstart

  1. Sign in to the dashboard > Profile.
  2. In the Developer API section, generate an API key.
  3. Copy the key (shown only once, format wbf_live_...).
  4. Test it right away with curl:
# Test your key (should return your account info)
curl -H "Authorization: Bearer wbf_live_xxxxxxxxxxxxxxxxxxxx" \
     https://webful.com/api/v1/account

Examples in other languages: JavaScript / Python / PHP.

Multi-language examples

The blocks below show how to call GET /account in each language. The pattern is identical for every endpoint: Bearer token in the Authorization header, JSON in response. Just substitute the path and query params.

Never embed the key in a public frontend. Call the API from your backend or a serverless function (Cloudflare Worker, Vercel, Netlify).

cURL

curl -H "Authorization: Bearer $WEBFUL_KEY" \
     -H "Accept: application/json" \
     "https://webful.com/api/v1/account"

JavaScript (fetch, Node 18+ / browser)

const res = await fetch('https://webful.com/api/v1/account', {
  headers: {
    'Authorization': `Bearer ${process.env.WEBFUL_KEY}`,
    'Accept':        'application/json',
  },
});

if (!res.ok) {
  const err = await res.json();
  throw new Error(`${err.error.code}: ${err.error.message}`);
}

const { data, meta } = await res.json();
console.log(data.plan, data.subscription_status);
// Rate limit: res.headers.get('X-RateLimit-Remaining')

Python (requests)

import os, requests

headers = {
    "Authorization": f"Bearer {os.environ['WEBFUL_KEY']}",
    "Accept":        "application/json",
}
res = requests.get("https://webful.com/api/v1/account", headers=headers, timeout=10)

if res.status_code != 200:
    err = res.json()["error"]
    raise RuntimeError(f"{err['code']}: {err['message']} (request_id={err['request_id']})")

payload = res.json()
print(payload["data"]["plan"], payload["data"]["subscription_status"])
# Rate limit: res.headers["X-RateLimit-Remaining"]

PHP (cURL)

<?php
$ch = curl_init('https://webful.com/api/v1/account');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . getenv('WEBFUL_KEY'),
        'Accept: application/json',
    ],
    CURLOPT_TIMEOUT        => 10,
]);
$body   = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

$json = json_decode($body, true);

if ($status !== 200) {
    throw new RuntimeException(
        $json['error']['code'] . ': ' . $json['error']['message']
    );
}

echo $json['data']['plan'], "\n";

With query parameters

Example: GET /sites/WBF-12345/pages?from=2026-04-01&to=2026-04-30&sort=visits&per_page=100

# JavaScript
const qs = new URLSearchParams({
  from: '2026-04-01', to: '2026-04-30',
  sort: 'visits', per_page: '100',
});
await fetch(`https://webful.com/api/v1/sites/WBF-12345/pages?${qs}`, { headers });

# Python
params = {"from": "2026-04-01", "to": "2026-04-30",
          "sort": "visits", "per_page": 100}
requests.get(url, headers=headers, params=params)

No-code integrations

  • n8n / Make / Zapier: use a GET "HTTP Request" node, add the header Authorization: Bearer {{key}}, auth mode "None" (auth is handled by the custom header). The response JSON is parsed directly by the next node.
  • Postman / Insomnia / Bruno: import the OpenAPI 3.0 schema to automatically generate a collection with all 18 endpoints and their parameters.
  • Google Sheets / Excel: via Apps Script (UrlFetchApp.fetch) or Power Query. Store the key in Script Properties (Sheets) or a secure vault (Excel).

Authentication

All authenticated requests use a Bearer token in the Authorization header. The key is tied to your account and grants access to all account data (every site, every endpoint).

Authorization: Bearer wbf_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
Accept: application/json

To revoke a key, regenerate it or use the Revoke button in the dashboard. The old key stops working immediately.

Granular scopes (read-only key, restricted to specific sites, multiple dev/staging/prod keys): not available in v1, planned for v2.

Sandbox mode: not available in v1. To test without affecting your real data, create a dedicated account or a test site. A true sandbox (fixture dataset and wbf_test_ keys) is planned for v2.

Rate limiting

The quota is counted per API key (not per account, not per IP) over a sliding one-hour window. Each new request "frees up" the oldest one leaving the window; there is no hard reset.

PlanLimitWindow
Standard1,000 req1 hour sliding
Agency5,000 req1 hour sliding

Every response includes the headers:

  • X-RateLimit-Limit: hourly limit applicable to the key
  • X-RateLimit-Remaining: requests remaining in the current window
  • X-RateLimit-Reset: Unix timestamp (seconds) at which the oldest hit will leave the window (indicative, the window is sliding)

When exceeded: HTTP 429 with code RATE_LIMITED and a Retry-After header.

Errors

Errors return a standardized JSON payload. The code list is closed (UPPER_SNAKE_CASE): any new value will be documented before being introduced. The code, message, documentation_url and request_id fields are always present. The details field is optional: it is provided for BAD_REQUEST (object describing the offending parameter) and sometimes for RATE_LIMITED (quota/window). Do not rely on it being systematically present.

{ "error": { "code": "BAD_REQUEST", "message": "Invalid period. Allowed values: 1, 7, 30, 90 (days).", "documentation_url": "https://webful.fr/api-docs#bad-request", "request_id": "01KPV9CMHTV7W7915410A1CS91", "details": { "param": "period", "allowed": [1, 7, 30, 90] } } }
HTTPCodeMeaningLikely cause
400BAD_REQUESTInvalid or missing parameterValue outside enum, incorrect date format, per_page out of bounds.
401UNAUTHORIZEDAuthentication failedMissing Authorization header, invalid or revoked key, incorrect key format.
403FORBIDDENAccess deniedUnverified email, suspended account, resource belonging to another account.
404NOT_FOUNDUnknown resource or endpointInvalid site ID, non-existent endpoint URL.
429RATE_LIMITEDHourly quota exceededSee Retry-After and X-RateLimit-Reset headers.
500INTERNAL_ERRORServer errorBug on the WEBFUL side. Share the request_id with support.

Enumerations

The following fields are closed enums: any returned value is guaranteed to be in the list. Additions will be backwards-compatible (announced in the changelog, never silently).

FieldValues
account.planfree | solo | pro | agency | agency_plus | enterprise
account.subscription_statusactive | trialing | past_due | canceled | incomplete | inactive
site.planfree | premium
The plan attached to a site (distinct from the account plan). free = 1,000 visits/month quota, premium = 100,000.
site.integration_typewordpress | html | shopify | prestashop | wix | squarespace
site.report_frequencynone | daily | weekly | monthly
site.report_modetraffic_only | traffic_plus_perf
status.activityok | warning | error
status.onlineok | warning | error | na
status.pluginok | warning | pending
status.perfok | warning | error | na
status.js_errorsok | error | na
alert.severityinfo | low | medium | high | critical
alert.code no_visit_ever, no_visit_recent, site_offline, plugin_pending, plugin_outdated, perf_warning, perf_critical, js_errors, traffic_drop
Depending on the code, the alert object may contain contextual fields in addition to code and severity:
  • no_visit_recent: days_since_visit (int)
  • site_offline: http_status (int)
  • plugin_outdated: current (string), latest (string)
  • perf_warning / perf_critical: score (int 0-100)
  • js_errors: count (int)
  • traffic_drop: percent (float, negative change as %)
metrics.trend_reliabilityfull | partial | none
stats.granularity (param)hour | day | month
pages.sort (param)visits | unique_visitors | avg_time
referrers.categorydirect | search | social | ai | referral | internal
The category parameter additionally accepts the value all.
events.view (param)aggregate | stream
conversions.view (param)aggregate | stream
conversions.conversion_typetel_click | email_click | form_submit | page_visit | outbound_click | file_download
The type parameter additionally accepts the value all.
performance.device (param)mobile | desktop | both
performance.report.statusexcellent | good | average | poor
seo.view (param)list | report
geolocation.by (param)country | region | city
funnel.step.typepageview | conversion | event
funnel.period (param)1 | 7 | 30 | 90 | 365 (days, sliding window)
webhook_delivery.statuspending | success | failed | retrying
The status parameter additionally accepts the value all.
webhook.event_type traffic.alert | health.critical | health.recovered | conversion.new | error.js_recurring
threshold_value (integer, nullable) is only used for error.js_recurring (JS error threshold that triggers the webhook). null for every other type.

Available endpoints

GET /api/v1/account

Demo

Information about the account associated with the API key (id, email, plan, subscription status).

Freshness: real-time.

Example:

# Ping: verifies the key and returns the account info
curl -H "Authorization: Bearer $WEBFUL_KEY" \
     https://webful.com/api/v1/account

Response:

{
  "data": {
    "id": 42,
    "email": "alice@example.com",
    "plan": "pro",
    "subscription_status": "active",
    "current_period_end": "2026-05-15"    // date-only, null if subscription has no known end date (free / canceled)
  },
  "meta": {
    "generated_at": "2026-04-22T15:30:00Z",
    "api_version": "v1",
    "request_id": "01KPV9CC3MA9641GSF1CW7SBKA",
    "rate_limit": { "limit": 1000, "remaining": 987, "reset": 1776888389 }
  }
}

GET /api/v1/sites

Demo

Paginated list of sites belonging to the account. Sort: created_at DESC (newest first).

Freshness: real-time.

Parameters:

  • page (int >= 1, default 1)
  • per_page (int, min 1, default 50, max 200)
curl -H "Authorization: Bearer $WEBFUL_KEY" \
     "https://webful.com/api/v1/sites?per_page=20"

Response (excerpt):

{
  "data": [
    {
      "site_id": "WBF-12345",
      "name": "My site",
      "url": "https://example.com",
      "aliases": ["www.example.com"],
      "thumbnail_url": "https://webful.fr/uploads/thumbnails/WBF-12345.webp?v=1768234689",
      "plan": "premium",
      "integration_type": "wordpress",
      "plugin_version": "2.5.3",  // null if integration_type != "wordpress" or plugin not yet installed
      "is_plugin_outdated": false,    // bool or null (see note)
      "tracking_verified": true,     // manual opt-in via the dashboard (see note)
      "tracking_active": true,        // derived: first_visit_at != null
      "report_frequency": "weekly",
      "report_mode": "traffic_plus_perf",
      "created_at": "2026-01-15T09:22:10Z"
    }
  ],
  "pagination": {
    "page": 1, "per_page": 20, "total": 4, "total_pages": 1
  },
  "meta": {
    "latest_plugin_version": "2.5.3",   // reference for is_plugin_outdated
    "request_id": "01KPV9CG8XD1G6D5EXDXMTKJJ2"
    /* generated_at, api_version, rate_limit */
  }
}

Derived fields: is_plugin_outdated = true if plugin_version < latest_plugin_version (semver), false otherwise, null if not applicable (integration_type != "wordpress", or plugin not yet installed). tracking_active = true if at least one visit has been recorded (first_visit_at != null). Answers the question "is the tracker working?". Distinct from tracking_verified, which is a manual opt-in: the user clicked "Verify my tracking" in the dashboard and WEBFUL fetched the HTML to confirm the snippet is present. A site can have tracking_active: true and tracking_verified: false (tracker in place but manual verification never triggered). This is normal.

GET /api/v1/sites/{site_id}

Demo

Full details for a site: metadata, plan, current-month quota, last health check, latest performance and SEO snapshots.

Freshness: metadata and quota = real-time; health.* = latest health check (~30 min); performance and seo = latest analysis batch (daily).

Path parameters:

  • site_id (format WBF-XXXXX): required. Must belong to the account of the key (otherwise NOT_FOUND).
curl -H "Authorization: Bearer $WEBFUL_KEY" \
     https://webful.com/api/v1/sites/WBF-12345

Response:

{
  "data": {
    "site_id": "WBF-12345",
    "name": "My site",
    "url": "https://example.com",
    "aliases": ["www.example.com"],
    "thumbnail_url": "https://webful.fr/uploads/thumbnails/WBF-12345.webp?v=1768234689",
    "plan": "premium",
    "integration_type": "wordpress",
    "plugin_version": "2.5.3",  // null if integration_type != "wordpress" or plugin not yet installed
    "is_plugin_outdated": false,    // bool or null (see note under /sites)
    "tracking_verified": true,     // manual opt-in via the dashboard
    "tracking_verified_at": "2025-10-21T13:56:21Z",
    "tracking_active": true,        // derived: first_visit_at != null (= "the tracker is seeing visits")
    "report_frequency": "weekly",
    "report_mode": "traffic_plus_perf",
    "created_at": "2025-10-21T13:32:09Z",
    "first_visit_at": "2025-10-21T13:56:21Z",
    "last_visit_at": "2026-04-22T10:45:00Z",
    "quota": {
      "visits_this_month": 1846,
      "monthly_limit": 100000,       // null if plan has no known limit
      "window": "calendar_month"
    },
    "health": {
      "is_online": true,
      "http_status": 200,
      "response_time_ms": 289,
      "checked_at": "2026-04-22T14:43:38Z"
    },                                      // null if no health check yet
    "performance": {
      "score": 65,
      "device": "mobile",
      "analyzed_at": "2026-04-20T23:01:23Z"
    },                                      // null if never analyzed
    "seo": {
      "score": 82,
      "analyzed_at": "2026-04-01T16:41:53Z"
    }                                       // null if never analyzed
  },
  "meta": {
    "generated_at": "2026-04-22T19:50:17Z",
    "api_version": "v1",
    "request_id": "01KPVBWPXG0KG0FXV12D7Q5AAH",
    "site_id": "WBF-12345",
    "rate_limit": { "limit": 1000, "remaining": 992, "reset": 1776891106 }
  }
}

quota.monthly_limit is null if the site's plan has no documented limit in v1 (e.g. legacy custom plan). In that case, the visits_this_month field remains valid as a simple counter.

The health, performance and seo blocks are null as long as no run has occurred. Never assume they are present; test !== null before accessing subfields.

GET /api/v1/sites/{site_id}/stats

Demo

Totals and aggregated time series for a site over a period. The anti-bot filter (sessions with ≥ 3 distinct IPs) is applied automatically.

Freshness: real-time (tracker database written live).

Parameters:

  • from (YYYY-MM-DD, default D-30)
  • to (YYYY-MM-DD, default today). Max window: 365 days.
  • granularity (closed enum hour | day | month, default day). Value outside the list = BAD_REQUEST.

The hour granularity returns buckets in ISO 8601 UTC format (2026-04-22T14:00:00Z). day uses YYYY-MM-DD, month uses YYYY-MM. Buckets with no traffic are absent from the series (not filled with zeros).

curl -H "Authorization: Bearer $WEBFUL_KEY" \
     "https://webful.com/api/v1/sites/WBF-12345/stats?from=2026-04-01&to=2026-04-22&granularity=day"

Response (excerpt):

{
  "data": {
    "totals": {
      "page_views": 2237,
      "unique_visitors": 1049,
      "sessions": 1484,
      "bounces": 1170,
      "bounce_rate": 78.8,            // % rounded to 1 decimal
      "avg_time_on_page": 1576,       // seconds, capped at 1800 per visit (0 if no data)
      "conversions": 498             // conversions table, anti-bot filter applied
    },
    "timeseries": [
      { "bucket": "2026-04-01", "page_views": 43, "unique_visitors": 22, "sessions": 25 },
      { "bucket": "2026-04-02", "page_views": 48, "unique_visitors": 29, "sessions": 34 }
      /* ... sort: bucket ASC */
    ]
  },
  "meta": {
    "generated_at": "2026-04-22T19:50:40Z",
    "api_version": "v1",
    "request_id": "01KPVBX9PY43Z49SJ7CKX7SSMF",
    "site_id": "WBF-12345",
    "period": { "from": "2026-04-01", "to": "2026-04-22" },
    "granularity": "day",
    "rate_limit": { "limit": 1000, "remaining": 991, "reset": 1776891106 }
  }
}

GET /api/v1/sites/{site_id}/pages

Demo

Top pages viewed over the period, aggregated by url. Anti-bot filter applied.

Freshness: real-time.

Parameters:

  • from, to (YYYY-MM-DD; default D-30 / today; max 365 days)
  • page, per_page (pagination, max 200)
  • sort (closed enum visits | unique_visitors | avg_time, default visits)

Sort: requested criterion DESC, then url ASC for determinism. avg_time ignores visits with zero time (empty or 0) and is capped at 1800 s (30 min) per visit to neutralize forgotten tabs (GA/Matomo standard). Actual durations above that are therefore counted as 1800.

curl -H "Authorization: Bearer $WEBFUL_KEY" \
     "https://webful.com/api/v1/sites/WBF-12345/pages?per_page=10&sort=visits"

Response (excerpt):

{
  "data": [
    {
      "url": "https://example.com/",
      "title": "Example - Home",        // may be null
      "visits": 554,
      "unique_visitors": 358,
      "avg_time": 1107                  // seconds, capped at 1800 per visit (0 if unknown)
    }
  ],
  "pagination": { "page": 1, "per_page": 10, "total": 87, "total_pages": 9 },
  "meta": {
    "request_id": "01KPVBX9R7RPNJ7E5YA4VC0V71",
    "site_id": "WBF-12345",
    "period": { "from": "2026-03-23", "to": "2026-04-22" },
    "sort": "visits",
    "rate_limit": { "limit": 1000, "remaining": 990, "reset": 1776891106 }
    /* generated_at, api_version also present */
  }
}

GET /api/v1/sites/{site_id}/referrers

Demo

Traffic sources aggregated by normalized host (host lowercased, without www.) and categorized. Empty or invalid referrers are grouped under (direct).

Freshness: real-time. Anti-bot filter applied.

Parameters:

  • from, to (YYYY-MM-DD; default D-30 / today; max 365 days)
  • page, per_page (pagination, max 200)
  • category (closed enum all | direct | search | social | ai | referral | internal, default all). Filters on the returned category.

Categorization: search (google, bing, yahoo, ddg, yandex, baidu, ecosia, qwant), social (facebook, x/twitter, linkedin, instagram, pinterest, reddit, tiktok, youtube, whatsapp), ai (chatgpt, claude, perplexity, gemini, copilot, you.com), internal (host of the site itself), direct (no referrer), referral (anything else). Sort: visits DESC, then host ASC.

curl -H "Authorization: Bearer $WEBFUL_KEY" \
     "https://webful.com/api/v1/sites/WBF-12345/referrers?category=search"

Response (excerpt):

{
  "data": [
    { "host": "google.com", "category": "search", "visits": 429 },
    { "host": "bing.com",   "category": "search", "visits": 47 }
  ],
  "pagination": { "page": 1, "per_page": 50, "total": 5, "total_pages": 1 },
  "meta": {
    "request_id": "01KPVBZ962S72CWF1M6W99002W",
    "site_id": "WBF-12345",
    "period": { "from": "2026-03-23", "to": "2026-04-22" },
    "category": "search",
    "rate_limit": { "limit": 1000, "remaining": 989, "reset": 1776891106 }
  }
}

GET /api/v1/sites/{site_id}/events

Demo

Custom events tracked (tracker webful.track('name', {...})). Two views: aggregated by event_name, or raw chronological stream.

Freshness: real-time. Anti-bot filter applied (same logic as /stats, /pages, /conversions).

Parameters:

  • from, to (YYYY-MM-DD; default D-30 / today; max 365 days)
  • view (closed enum aggregate | stream, default aggregate)
  • event_name (free string, max 100 characters, strict match). Not a closed enum: integrators use their own names. Works with both view=aggregate AND view=stream.
  • page, per_page (pagination, max 200) — only relevant in view=stream. In view=aggregate, the row count is bounded by the number of distinct event_name values (rarely > 50); paginating serves no purpose.

Sort: aggregate = count DESC, then event_name ASC. stream = timestamp DESC (newest first).

# Aggregate view: all event_names
curl -H "Authorization: Bearer $WEBFUL_KEY" \
     "https://webful.com/api/v1/sites/WBF-12345/events"

# Aggregate view, filtered on one event name (returns a single row)
curl -H "Authorization: Bearer $WEBFUL_KEY" \
     "https://webful.com/api/v1/sites/WBF-12345/events?event_name=signup"

# Chronological stream, filtered on one event name
curl -H "Authorization: Bearer $WEBFUL_KEY" \
     "https://webful.com/api/v1/sites/WBF-12345/events?view=stream&event_name=signup&per_page=50"

Response view=aggregate:

{
  "data": [
    {
      "event_name": "signup",
      "count": 142,
      "unique_sessions": 128,
      "first_seen": "2026-03-23T12:04:18Z",
      "last_seen": "2026-04-22T18:32:09Z"
    }
  ],
  "pagination": { "page": 1, "per_page": 50, "total": 8, "total_pages": 1 },
  "meta": {
    "site_id": "WBF-12345",
    "view": "aggregate",
    "event_name": null,             // filter active if not null
    "period": { "from": "2026-03-23", "to": "2026-04-22" }
    /* request_id, generated_at, api_version, rate_limit */
  }
}

Response view=stream:

{
  "data": [
    {
      "id": 98412,
      "event_name": "signup",
      "data": { "plan": "pro", "source": "pricing" },  // decoded JSON if possible, otherwise raw string, otherwise null
      "session_id": "WBF-1776784067916-v9p4anhjc",
      "timestamp": "2026-04-22T18:32:09Z"
    }
  ],
  "pagination": { "page": 1, "per_page": 50, "total": 1284, "total_pages": 26 }
}

GET /api/v1/sites/{site_id}/conversions

Demo

Definition: a conversion is an event explicitly marked as such by the tracker (conversions table, distinct from custom events /events and from visits /stats). Types are a closed enum of 6 values.

Freshness: real-time. Anti-bot filter applied: session_id values whose page_views show ≥ 3 distinct ip_hash in the window are excluded, just like for /stats, /pages, /referrers. The conversions/sessions ratio is therefore consistent across all endpoints.

Parameters:

  • from, to (YYYY-MM-DD; default D-30 / today; max 365 days)
  • type (closed enum all | tel_click | email_click | form_submit | page_visit | outbound_click | file_download, default all)
  • view (closed enum aggregate | stream, default aggregate)
  • page, per_page (pagination, max 200; only used in view=stream)

Sort: aggregate = count DESC then conversion_type ASC; stream = timestamp DESC, then id DESC.

curl -H "Authorization: Bearer $WEBFUL_KEY" \
     "https://webful.com/api/v1/sites/WBF-12345/conversions?view=aggregate"

Response view=aggregate:

{
  "data": [
    {
      "conversion_type": "page_visit",
      "count": 265,
      "unique_sessions": 159,
      "first_seen": "2026-03-23T14:06:20Z",
      "last_seen": "2026-04-21T13:10:02Z"
    },
    {
      "conversion_type": "form_submit",
      "count": 178,
      "unique_sessions": 72,
      "first_seen": "2026-03-25T23:24:05Z",
      "last_seen": "2026-04-19T09:15:04Z"
    }
  ],
  "meta": {
    "site_id": "WBF-12345",
    "period": { "from": "2026-03-23", "to": "2026-04-22" },
    "view": "aggregate",
    "type": "all",
    "total_conversions": 498,         // see note under the example
    "rate_limit": { "limit": 1000, "remaining": 987, "reset": 1776891106 }
  }
}

meta.total_conversions: total number of conversions in the period (after anti-bot filter), honoring the type parameter. With type=all, it's the sum of all rows.count in the aggregate view. With a filter (type=form_submit), it's the total for that type only. Field present in both views (aggregate and stream).

Response view=stream:

{
  "data": [
    {
      "id": 74218,
      "conversion_type": "form_submit",
      "label": "contact-form",          // label attached by the tracker, may be null
      "page_url": "https://example.com/contact",
      "referrer": "https://google.com/",      // may be null
      "device_type": "mobile",              // may be null, values: mobile | desktop | tablet
      "session_id": "WBF-1776784067916-v9p4anhjc",
      "visitor_id": "WBF-1776784068139-o9dkc0wfy",  // same format as session_id (see note below)
      "timestamp": "2026-04-19T09:15:04Z"
    }
  ],
  "pagination": { "page": 1, "per_page": 50, "total": 498, "total_pages": 10 }
}

The visitor_id field is a pseudonymous, non-tracking identifier: it does not allow re-identifying an individual outside the site in question. Do not store it as a long-term user key.

Contract note: WEBFUL does not perform cross-session re-identification. In practice, visitor_id therefore currently matches session_id (1 visit = 1 identifier). The two fields remain distinct to avoid breaking integrations if a persistent visitor opt-in mode is added later. Do not assume visitor_id vs session_id uniqueness in your calculations.

GET /api/v1/agency/overview

Demo

Agency view snapshot: for each site, health indicators, metrics (visits, trend, perf/SEO scores), alerts and overall health score.

Freshness: visits real-time; perf_score / seo_score = latest analysis batch (daily); online indicator = health check every 30 minutes.

Parameters:

  • period (closed enum 1 | 7 | 30 | 90, default 30): comparison window in days. Value outside the list = BAD_REQUEST.

Why a closed enum here, when /sites/{id}/stats accepts free from/to? /agency/overview is a synthetic snapshot view (indicators, alerts, overall health score), not a free-form stats endpoint. The 4 values 1 | 7 | 30 | 90 correspond to the windows for which trend_percent reliability is calibrated (trend_reliability) and whose "period vs previous period" computation stays performant even with many sites. For free-form ranges, use /sites/{id}/stats (up to 365 days via from/to).

trend_percent

Compares visits in the current period to the previous period of the same length (e.g. period=7 compares the last 7 days to the 7 days before). Values: -100 to +inf, rounded to 1 decimal. 0 if no traffic in the previous period, 100 for brand new traffic. The trend_reliability field (full | partial | none) indicates whether the comparison is statistically significant.

curl -H "Authorization: Bearer $WEBFUL_KEY" \
     "https://webful.com/api/v1/agency/overview?period=7"

Response (excerpt):

{
  "data": {
    "summary": {
      "total_sites": 4,
      "total_visits": 12840,
      "period_days": 7,
      "avg_trend": 12.3,
      "avg_perf_score": 87,
      "total_alerts": 2
    },
    "sites": [
      {
        "site_id": "WBF-12345",
        "name": "My site",
        "url": "https://example.com",
        "thumbnail_url": "https://webful.fr/uploads/thumbnails/WBF-12345.webp?v=1768234689",
        "integration_type": "wordpress",
        "plugin_version": "2.5.3",  // null if integration_type != "wordpress" or plugin not yet installed
        "is_plugin_outdated": false,    // bool or null (see note under /sites)
        "tracking_active": true,        // derived: at least one visit recorded
        "report_frequency": "weekly",
        "report_mode": "traffic_plus_perf",
        "created_at": "2026-01-15T09:22:10Z",
        "status": {
          "activity": "ok",    // see Enumerations section
          "online": "ok",
          "plugin": "ok",
          "perf": "warning",
          "js_errors": "ok"
        },
        "metrics": {
          "visits": 3210,
          "visits_previous": 2787,
          "trend_percent": 15.2,
          "trend_reliability": "full",
          "perf_score": 64,
          "seo_score": 82,
          "last_visit": "2026-04-22T10:45:00Z",
          "days_since_visit": 0,
          "js_errors_24h": 0
        },
        "health_score": 80,
        "alerts": [                  // see Enumerations section: code + severity + variable contextual fields
          { "code": "perf_warning",  "severity": "low",    "score": 64 },
          { "code": "traffic_drop",  "severity": "medium", "percent": -39.3 }
        ]
      }
    ]
  },
  "meta": {
    "generated_at": "2026-04-22T15:30:00Z",
    "api_version": "v1",
    "request_id": "01KPV9CMHTV7W7915410A1CS91",
    "period": { "days": 7, "label": "7d" },
    "latest_plugin_version": "2.5.3",
    "rate_limit": { "limit": 5000, "remaining": 4987, "reset": 1776888389 }
  }
}

meta.period echoes the parameter actually used (after validation); meta.latest_plugin_version = latest stable version of the WordPress WEBFUL plugin, used as a reference for the status.plugin indicator.

The sites returned include the same fields as /api/v1/sites (url, thumbnail_url, integration_type, etc.) to avoid an N+1 follow-up call.

status.plugin for non-WordPress sites (HTML integration, Shopify, etc.): ok as soon as traffic is detected, pending as long as no visit has been recorded. No na or warning in this case.

GET /api/v1/account/usage

Demo

Account monthly quota consumption: views used this month, plan cap, percentage, per-site breakdown.

Freshness: real-time (updated on every trackable hit). Window: UTC calendar month (YYYY-MM). Resets on the 1st of each month at 00:00 UTC.

Parameters:

None.

curl -H "Authorization: Bearer $WEBFUL_KEY" \
     "https://webful.com/api/v1/account/usage"

Response:

{
  "data": {
    "period": "2026-04",
    "views_used": 12840,
    "views_limit": 100000,        // null if unlimited plan
    "views_remaining": 87160,    // null if unlimited
    "percentage": 12.84,         // null if unlimited
    "unlimited": false,
    "per_site": [
      { "site_id": "WBF-12345", "views": 8210 },
      { "site_id": "WBF-67890", "views": 4630 }
    ],
    "resets_at": "2026-05-01T00:00:00Z",
    "api_requests_last_24h": 272
  },
  "meta": {
    "plan": "agency",
    "rate_limit": { "limit": 5000, "remaining": 4999, "reset": 1776944400 }
  }
}

per_site[].views is guaranteed consistent with quota.visits_this_month returned by /sites/{id}: same source, same window (UTC calendar month), same anti-bot filter.
api_requests_last_24h: number of API requests made by this account over the last 24 rolling hours, across all API keys, all HTTP statuses combined (2xx, 4xx, 5xx). Not to be confused with meta.rate_limit, which is scoped per key over a 1-hour window.

GET /api/v1/sites/{site_id}/performance

Demo

PageSpeed Insights scores from the latest batch + up to 90 days of history. Source: performance_analyses table, populated by a daily CRON (~03:00 UTC).

Freshness: daily batch. A site that has not yet been analyzed returns latest = null (or latest.{mobile|desktop} = null in device=both mode), never zeros. X-Data-Freshness header returned with the ISO timestamp of the latest available batch.

Parameters:

  • device (closed enum mobile | desktop | both, default mobile)
  • history_days (int 0-90, default 30): 0 = no history returned (only latest)

History sort: analyzed_at ASC (oldest to newest).

curl -H "Authorization: Bearer $WEBFUL_KEY" \
     "https://webful.com/api/v1/sites/WBF-12345/performance?device=mobile&history_days=30"

Response:

{
  "data": {
    "latest": {
      "analyzed_at": "2026-04-22T03:12:44Z",
      "device_type": "mobile",
      "scores": {
        "performance": 92,    // 0-100 or null
        "accessibility": 89,
        "seo": 95,
        "best_practices": 100
      },
      "metrics": {
        "lcp_seconds": 1.06,
        "fid_ms": 16,
        "cls": 0,
        "fcp_seconds": 1.06,
        "ttfb_seconds": 0.2,
        "speed_index_seconds": 1.83,
        "tbt_ms": 0
      },
      "report": {
        "status": "excellent",     // see Enumerations: excellent | good | average | poor
        "message": "Great job! Your site is perfectly optimized."
      }
    },
    "history": [
      { "analyzed_at": "2026-03-23T03:12:44Z", "device_type": "mobile",
        "performance": 88, "accessibility": 89, "seo": 95, "best_practices": 100,
        "lcp_seconds": 1.4, "cls": 0, "tbt_ms": 20 }
    ]
  },
  "meta": {
    "site_id": "WBF-12345",
    "device": "mobile",
    "history_days": 30,
    "data_source": "pagespeed_insights",
    "refresh_cadence": "daily"
  }
}

Response (device=both, excerpt):

{
  "data": {
    "latest": {
      "mobile":  { "analyzed_at": "2026-04-22T03:12:44Z",
                  "scores": { "performance": 92, ... },
                  "metrics": { ... }, "report": { ... } },
      "desktop": { "analyzed_at": "2026-04-22T03:14:02Z",
                  "scores": { "performance": 98, ... },
                  "metrics": { ... }, "report": { ... } }
      // If one of the two devices has not yet been analyzed: null instead of the object.
    },
    "history": [
      { "analyzed_at": "2026-04-21T03:11:02Z", "device_type": "mobile",  "performance": 90, ... },
      { "analyzed_at": "2026-04-21T03:11:48Z", "device_type": "desktop", "performance": 97, ... }
      // Mobile and desktop entries are interleaved, sorted by analyzed_at ASC.
      // Filter client-side on device_type if a separate chart per device is desired.
    ]
  },
  "meta": { "site_id": "WBF-12345", "device": "both", "history_days": 30,
            "data_source": "pagespeed_insights", "refresh_cadence": "daily" }
}

In device=both mode, data.latest becomes an object {"mobile": ..., "desktop": ...}. If only one device has been analyzed, the missing key is null (not an empty object). The history returns the analyses for both devices interleaved, each carrying its own device_type.

GET /api/v1/sites/{site_id}/seo

Demo

Stored SEO analyses: list of analyzed URLs (view=list) or detailed report for a specific URL (view=report).

Freshness: on-demand batch (triggered manually from the SEO tool in the dashboard). URLs never analyzed do not appear.

Parameters:

  • view (closed enum list | report, default list)
  • url (string, required if view=report): exact analyzed URL (max 500 characters)
  • page, per_page (pagination, max 200; only used in view=list)

Sort (view=list): analyzed_at DESC, then id DESC.

curl -H "Authorization: Bearer $WEBFUL_KEY" \
     "https://webful.com/api/v1/sites/WBF-12345/seo?view=list"

Response view=list:

{
  "data": [
    {
      "url": "https://example.com/",
      "score": 88,
      "score_previous": 82,                    // nullable: null on first analysis
      "score_delta": 6,                         // nullable: null on first analysis
      "analyzed_at": "2026-04-15T10:22:00Z"
    }
  ],
  "pagination": { "page": 1, "per_page": 50, "total": 12, "total_pages": 1 },
  "meta": {
    "site_id": "WBF-12345",
    "view": "list",
    "data_source": "webful_seo_analyzer",
    "refresh_cadence": "on_demand"     // manual rerun from the dashboard
  }
}

Response view=report:

{
  "data": {
    "url": "https://example.com/",
    "score": 88,
    "score_previous": 82,                      // nullable
    "score_delta": 6,                           // nullable
    "analyzed_at": "2026-04-15T10:22:00Z",
    "results": [
      { "rule_id": "title_exists",    "passed": true,  "message": "Title tag present",
        "value": "Home - Example" },         // string
      { "rule_id": "h1_count",        "passed": true,  "message": "1 H1 tag",
        "value": 1 },                       // integer
      { "rule_id": "text_html_ratio", "passed": true,  "message": "Text/HTML ratio looks good",
        "value": 0.27 },                    // float
      { "rule_id": "mobile_viewport", "passed": true,  "message": "Mobile viewport present",
        "value": true },                    // boolean
      { "rule_id": "broken_links",    "passed": false, "message": "3 broken link(s)",
        "value": {                          // object (structure specific to rule_id)
          "total_links": 42,
          "broken_count": 3,
          "broken_links": ["/old-page", "https://dead.example.com"]
        } },
      { "rule_id": "meta_description", "passed": false, "message": "Meta description missing",
        "value": null }                     // null
    ]
  },
  "meta": {
    "site_id": "WBF-12345",
    "view": "report",
    "data_source": "webful_seo_analyzer",
    "refresh_cadence": "on_demand"
  }
}

The rule_id values are stable across versions and serve as keys to map to your own labels. Full list via the dashboard (SEO section).

results[].value has a variable type depending on the rule_id and whether the rule passes or fails:

  • string: rules that return textual content (title_exists, h1_unique, meta_description).
  • integer: counting rules (title_length, word_count, external_scripts, og_tags).
  • float: ratios and scores (text_html_ratio, page_speed).
  • boolean: presence/absence of an HTML element (heading_structure, https, mobile_viewport, canonical).
  • object: failure details for some rules, structure specific to each rule_id. Observed examples: broken_links -> {total_links, broken_count, broken_links: [url, ...]}; images_alt -> {total, without_alt, missing_alt_images: [url, ...]}. The same rule can return an integer when it passes and an object when it fails (e.g. broken_links).
  • null: rule that failed before it could measure.

Code defensively client-side: test typeof value (or Array.isArray / is_array) before calling .toString(), a formatting method, or a template. The list above is indicative: new rules can be added in a minor version, and the type of value for a given rule can be extended (e.g. integer -> object) in a minor version too, as long as the type stays defensively parseable.

Each rule belongs to a category (critical | important | optimization) that determines its penalty; the category is not returned via the API for now.

GET /api/v1/sites/{site_id}/geolocation

Demo

Geographic distribution of traffic: countries, regions, or cities of visitors.

Freshness: real-time. IP resolution is delayed by a few minutes (async job), so very recent visits may still be ungeolocated and excluded from the total. Anti-bot filter applied.

Parameters:

  • from, to (YYYY-MM-DD; default D-30 / today; max 365 days)
  • by (closed enum country | region | city, default country)
  • country_code (ISO 3166-1 alpha-2, e.g. FR, US): filter to obtain regions/cities of a given country
  • page, per_page (max 200)

Sort: views DESC, then name ASC (determinism).

curl -H "Authorization: Bearer $WEBFUL_KEY" \
     "https://webful.com/api/v1/sites/WBF-12345/geolocation?by=city&country_code=FR"

Response (by=city, country_code=FR):

{
  "data": [
    {
      "country_code": "FR",
      "country_name": "France",
      "region_name": "Paris Department",
      "city_name": "Paris",
      "views": 1448,
      "unique_visitors": 61,
      "sessions": 248,
      "percentage": 70.77
    }
  ],
  "meta": {
    "site_id": "WBF-12345",
    "period": { "from": "2026-03-23", "to": "2026-04-22" },
    "by": "city",
    "country_code": "FR",                  // string | null: filter actually applied
    "total_views": 2046
  },
  "pagination": { "page": 1, "per_page": 50, "total": 4, "total_pages": 1 }
}

With by=country, the returned fields are country_code, country_name + metrics. With by=region, region_name is added. With by=city, region_name and city_name are added. percentage = share of the total (after filters) attributed to this group.
meta.country_code reflects the applied filter (null if not specified).

GET /api/v1/sites/{site_id}/user-flows

Demo

User flows: entry pages, exit pages, top sequences of pages visited, and engagement stats.

Freshness: real-time (direct aggregation on page_views). Anti-bot filter applied.

Parameters:

  • from, to (YYYY-MM-DD; default D-7 / today; max 365 days)
  • limit (int 1-50, default 10): size of the top lists (entry_pages, exit_pages, top_paths)
  • max_depth (int 2-10, default 10): maximum depth of a path kept in top_paths

Sort: entry_pages / exit_pages: count DESC, then url ASC. top_paths: occurrences DESC (paths seen fewer than 2 times are excluded).

curl -H "Authorization: Bearer $WEBFUL_KEY" \
     "https://webful.com/api/v1/sites/WBF-12345/user-flows?limit=5"

Response:

{
  "data": {
    "stats": {
      "total_sessions": 101,
      "avg_pages_per_session": 5.09,
      "bounce_rate": 65.35,
      "bounces": 66
    },
    "entry_pages": [
      { "url": "https://example.com/", "count": 20, "percentage": 19.8 }
    ],
    "exit_pages": [
      { "url": "https://example.com/contact", "count": 14, "percentage": 13.86 }
    ],
    "top_paths": [
      {
        "pages": ["https://example.com/", "https://example.com/pricing"],
        "depth": 2,
        "occurrences": 12,
        "percentage": 11.88
      }
    ]
  },
  "meta": {
    "site_id": "WBF-12345",
    "period": { "from": "2026-04-15", "to": "2026-04-22" },
    "limit": 5,                          // effective value of the limit parameter
    "max_depth": 10                       // effective value of the max_depth parameter
  }
}

GET /api/v1/sites/{site_id}/funnels

Demo

List of conversion funnels configured for the site. For conversion data of a specific funnel, use the detail endpoint below.

Freshness: real-time (configuration in DB).

Parameters:

  • page, per_page (max 200)

Sort: created_at DESC.

curl -H "Authorization: Bearer $WEBFUL_KEY" \
     "https://webful.com/api/v1/sites/WBF-12345/funnels"

Response:

{
  "data": [
    {
      "id": 1,
      "name": "Lead direct",
      "steps": [
        { "order": 1, "type": "pageview",   "value": "https://example.com",         "label": "Accueil" },
        { "order": 2, "type": "pageview",   "value": "https://example.com/contact", "label": "Contact" },
        { "order": 3, "type": "conversion", "value": "form_submit",               "label": "Form submitted" }
      ],
      "created_at": "2026-03-29T18:58:29Z",
      "updated_at": "2026-03-29T18:58:29Z"
    }
  ],
  "pagination": { "page": 1, "per_page": 50, "total": 1, "total_pages": 1 }
}

GET /api/v1/sites/{site_id}/funnels/{funnel_id}

Conversion data for a funnel: unique visitors at each step, dropoff, conversion rate.

Freshness: real-time. Window: sliding from now.

Parameters:

  • period (closed enum 1 | 7 | 30 | 90 | 365 days, default 30)
curl -H "Authorization: Bearer $WEBFUL_KEY" \
     "https://webful.com/api/v1/sites/WBF-12345/funnels/1?period=30"

Response:

{
  "data": {
    "id": 1,
    "name": "Lead direct",
    "total_entered": 400,
    "total_converted": 47,
    "conversion_rate": 11.75,
    "steps": [
      { "order": 1, "type": "pageview",   "value": "https://example.com",
        "label": "Accueil", "visitors": 400, "dropoff": 0,
        "rate_from_previous": 100, "rate_from_first": 100 },
      { "order": 2, "type": "pageview",   "value": "https://example.com/contact",
        "label": "Contact", "visitors": 72, "dropoff": 328,
        "rate_from_previous": 18, "rate_from_first": 18 },
      { "order": 3, "type": "conversion", "value": "form_submit",
        "label": "Form submitted", "visitors": 47, "dropoff": 25,
        "rate_from_previous": 65.28, "rate_from_first": 11.75 }
    ]
  },
  "meta": { "site_id": "WBF-12345", "funnel_id": 1, "period_days": 30, "window": "rolling" }
}

step.type = closed enum pageview | conversion | event (see Enumerations). The calculation deduplicates by ip_hash: a single visitor who completes the funnel across multiple sessions is counted once.
step.label: may be null in /funnels (list) if the label was not entered in configuration. In /funnels/{id} (detail), label falls back to value to guarantee a non-empty display on the client side.
meta.period_days (int) reflects the applied period parameter; meta.window is "rolling" (sliding window from now).
A funnel with no steps (incomplete configuration) returns steps: [] with total_entered: 0, total_converted: 0, conversion_rate: 0.

GET /api/v1/sites/{site_id}/webhooks

Demo

List of outgoing webhooks configured for this site (target URL, subscribed events, status of the last delivery).

Freshness: real-time. Security: the webhook's HMAC secret is never exposed by the API (view/regenerate via the dashboard only).

Parameters:

  • page, per_page (max 200)

Sort: created_at DESC.

curl -H "Authorization: Bearer $WEBFUL_KEY" \
     "https://webful.com/api/v1/sites/WBF-12345/webhooks"

Response:

{
  "data": [
    {
      "id": 17,
      "name": "Slack - Conversions",
      "url": "https://hooks.slack.com/services/...",
      "is_active": true,
      "events": [
        { "event_type": "conversion.new",        "threshold_value": null },
        { "event_type": "error.js_recurring",    "threshold_value": 5 }
      ],
      "last_delivery": {                      // null if no delivery yet
        "event_type": "conversion.new",
        "status": "success",                 // see Enumerations
        "response_code": 200,                  // nullable (network failure)
        "created_at": "2026-04-22T14:30:00Z",
        "delivered_at": "2026-04-22T14:30:00Z"
      },
      "created_at": "2026-03-12T10:00:00Z",
      "updated_at": "2026-04-01T08:15:00Z"
    }
  ],
  "pagination": { "page": 1, "per_page": 50, "total": 1, "total_pages": 1 }
}

Event types (closed enum, see Enumerations): traffic.alert, health.critical, health.recovered, conversion.new, error.js_recurring.
threshold_value (integer, nullable): used only for error.js_recurring (minimum number of recurring JS errors that triggers the webhook). null for all other event types.
last_delivery is null if the webhook has never been triggered. response_code is null in case of network failure (timeout, DNS, TLS) before receiving an HTTP response.


GET /api/v1/sites/{site_id}/webhooks/{webhook_id}/deliveries

Delivery history (payload sent, HTTP response code, status). The retry cycle is: initial attempt, then up to 5 retries with exponential backoff before giving up.

Freshness: real-time.

Parameters:

  • status (closed enum all | pending | success | failed | retrying, default all)
  • page, per_page (max 200)

Sort: created_at DESC, then id DESC.

curl -H "Authorization: Bearer $WEBFUL_KEY" \
     "https://webful.com/api/v1/sites/WBF-12345/webhooks/17/deliveries?status=failed"

Response:

{
  "data": [
    {
      "id": 4521,
      "delivery_id": "d-3c7b1ae2-4f80-4e8a-9c3e-2b1d8e0a1234",
      "event_type": "conversion.new",
      "status": "failed",
      "attempt": 5,
      "response_code": 500,                      // nullable (null on network failure)
      "response_body": "Internal Server Error",    // nullable, truncated to 2000 characters
      "payload": { "conversion_type": "form_submit", "site_id": "WBF-12345" },
      "created_at": "2026-04-22T10:00:00Z",
      "delivered_at": null,                      // null until successfully delivered
      "next_retry_at": null                       // null on success or give up
    }
  ],
  "pagination": { "page": 1, "per_page": 50, "total": 12, "total_pages": 1 },
  "meta": {
    "site_id": "WBF-12345",
    "webhook": { "id": 17, "name": "Slack - Conversions", "url": "https://...", "is_active": true },
    "status": "failed"
  }
}

delivery_id: stable delivery identifier, format d-<uuid-v4>. Included as the X-Webful-Delivery-Id header of the payload sent to the client, usable to correlate a delivery received on the receiver side with this history row.
attempt: attempt number (1 = initial, 2-5 = retries with exponential backoff).
next_retry_at is null if the delivery succeeded (status=success) or if all retries are exhausted (status=failed with attempt=5).

OpenAPI 3.0 schema

An OpenAPI 3.0.3 schema formally describes all endpoints, parameters, enums and response shapes. It is served in YAML at:

https://webful.com/api/v1/openapi.yaml

Typical uses:

  • SDK generation: openapi-generator-cli generate -i https://webful.com/api/v1/openapi.yaml -g python -o ./webful-client (replace python with typescript-fetch, go, php, etc.)
  • Import into Postman / Insomnia / Bruno: full collection generated automatically.
  • Import into n8n / Make: recent HTTP connectors accept the schema URL to pre-fill calls.
  • Interactive rendering: paste the URL into editor.swagger.io or redocly.github.io/redoc for navigable docs with "Try it out".

CORS open on the schema (header Access-Control-Allow-Origin: *): browser-side tools can fetch it directly. No authentication required.

The schema is hand-maintained alongside this page. Source of truth: this documentation; any observed divergence is a bug to report.

Versioning & breaking changes

  • Versioning in the URL: endpoints are prefixed with /api/v1/. A v2 will live alongside v1, not as a replacement.
  • Backwards-compatible changes (new optional field, new value added to an enum, new endpoint): shipped without notice, documented in the changelog. A defensive integrator must tolerate unknown fields.
  • Breaking changes (field removal, type change, enum restriction): only via a new major version. The previous version remains maintained for 6 months minimum after announcement.
  • Deprecation announcement channel:
    1. Email to owners of active API keys (account address)
    2. Public changelog on this page ("Changelog" section at the bottom)
    3. Headers Deprecation (RFC 8594) and Sunset returned on affected endpoints/fields starting from the announcement
  • Header X-API-Version: returned on every response (e.g. v1). Useful for diagnostics in case of a proxy that rewrites URLs.

Changelog

v1.0 — April 23, 2026

launch

Initial version of the WEBFUL public API. 18 read-only endpoints covering the entire dashboard: account, sites, statistics (stats, pages, referrers, events, conversions), performance, SEO, geolocation, user journeys, funnels, webhooks, multi-site agency view.

Bearer authentication with account-level API key, per-key rate limiting (1,000 req/h standard, 5,000 req/h agency), ISO 8601 UTC dates, request_id traced on every response, documented closed enumerations, standardized pagination, anti-bot filters applied on traffic endpoints, X-Data-Freshness on batch endpoints (performance, SEO).

Tooling: OpenAPI 3.0.3 schema served online (CORS open), integration examples in cURL, JavaScript, Python, PHP and no-code (n8n, Make, Postman, Google Sheets).

Future changes (backwards-compatible additions and breaking changes via v2) will be logged here and announced by email to owners of active keys.

Roadmap

v1 exposes the entire dashboard in read mode. The following areas are under consideration for upcoming versions:

  • Interactive rendering of the docs (Swagger UI / Redoc) powered by the OpenAPI schema already online.
  • Per-account configurable CORS: origin whitelist from the dashboard for controlled front-end use cases (beyond the default *).
  • Granular scopes (read-only keys, restricted to a subset of sites, dev/staging/prod keys) — requires a redesign of the keys table, planned for v2.
  • Sandbox mode with fictitious dataset and wbf_test_ keys — v2.
  • Mutations (POST/PUT/DELETE) for programmatic management of sites, funnels, webhooks — v2.
  • Official SDKs (JavaScript, Python, PHP) — v2.

Feedback to share? Write to us from the contact page.