Async client for the Matomo Reporting API
  • Rust 99.2%
  • Scheme 0.8%
Find a file
2026-06-08 18:40:50 +01:00
.github/workflows feat: initial release 2026-06-08 18:40:50 +01:00
examples feat: initial release 2026-06-08 18:40:50 +01:00
src feat: initial release 2026-06-08 18:40:50 +01:00
tests feat: initial release 2026-06-08 18:40:50 +01:00
.envrc feat: initial release 2026-06-08 18:40:50 +01:00
.gitignore feat: initial release 2026-06-08 18:40:50 +01:00
Cargo.toml feat: initial release 2026-06-08 18:40:50 +01:00
CHANGELOG.md feat: initial release 2026-06-08 18:40:50 +01:00
manifest.scm feat: initial release 2026-06-08 18:40:50 +01:00
README.md feat: initial release 2026-06-08 18:40:50 +01:00

matomo-rs

ci crates.io Documentation

An async Rust client for the Matomo Reporting API, built primarily for data export and migration — pull your visits, actions, and referrers out of Matomo and into whatever you're moving to.

Matomo's API isn't a typical REST surface: it's a single dispatch endpoint (POST /index.php with module=API&method=Module.action plus a shared parameter envelope). This crate wraps that in typed methods for the core reporting modules, with a generic escape hatch for everything else.

Status

Early but working — verified end-to-end against a live Matomo 5.8 instance. It covers the methods that matter for migration:

  • Live.getLastVisitsDetails — the raw, per-visit clickstream (the migration workhorse), with resumable paging.
  • VisitsSummary.get — aggregate metrics that can't be recomputed from raw data.
  • Actions.* — page URLs, page titles, downloads, outlinks.
  • Referrers.* — referrer types, full referrer list.
  • API.* — version, report metadata, bulk requests.

Anything not yet typed is reachable through call / call_typed / call_raw.

It's also transport-agnostic: the core (Client, Endpoint, Query) carries no HTTP client. A reqwest-backed transport ships behind a feature flag, or you bring your own by implementing Client.

Requirements

  • Matomo 5.x.
  • An API auth token. Default auth sends it as token_auth in the POST body, which works on all 5.x and keeps the token out of the URL. Authorization: Bearer is also supported, but only useful on Matomo 5.4+ and where the web server forwards the Authorization header to PHP — so the body token is the safer default.

Bring your own async runtime — the crate pulls in no runtime directly. It's tested against tokio.

Install

No HTTP client is pulled in by default. For the ready-made reqwest transport, enable a feature with a TLS backend:

[dependencies]
matomo-rs = { version = "0.1", features = ["reqwest", "reqwest-rustls"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
futures = "0.3"

Or cargo add matomo-rs --features reqwest,reqwest-rustls. Use reqwest-native-tls instead if you want the OS-native TLS stack.

The library name is matomo, so you use matomo::.... The reqwest-backed client lives at matomo::reqwest::MatomoClient.

Features

  • reqwest — the bundled MatomoClient transport. No TLS backend on its own; pair it with one below.
  • reqwest-rustlsreqwest with rustls.
  • reqwest-native-tlsreqwest with the OS-native TLS stack.
  • chrono / timeFrom conversions into Date.

All features are off by default.

Quick start

use matomo::reqwest::MatomoClient;
use matomo::{Auth, Period, Date};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = MatomoClient::builder()
        .base_url("https://stats.example.com/") // sub-paths like /matomo/ are fine
        .auth(Auth::token(std::env::var("MATOMO_TOKEN")?))
        .build()?;

    let summary = client
        .visits_summary()
        .get(1u32, Period::Month(Date::Today), None)
        .await?;

    println!("{} visits, {} unique", summary.nb_visits, summary.nb_uniq_visitors);
    Ok(())
}

Exporting raw visits (streaming)

Live.getLastVisitsDetails is paged. The stream auto-pages until the data runs out (an empty page is the authoritative terminator — a short page is not), and it's 'static + Send, so you can tokio::spawn it.

use futures::StreamExt;
use matomo::reqwest::MatomoClient;
use matomo::{Auth, DateRange, Limit, Period};

# async fn run(client: matomo::reqwest::MatomoClient) -> Result<(), Box<dyn std::error::Error>> {
let period = Period::Range(DateRange::ymd((2024, 1, 1), (2024, 12, 31)));
let page_size = Limit::count(500).unwrap();

let mut visits = client.live().stream(1, period, page_size, None)?;
while let Some(visit) = visits.next().await {
    let visit = visit?;
    println!("{} {:?} {:?}", visit.id_visit, visit.visitor_type, visit.country);
}
# Ok(())
# }

Resumable paging

For long exports that may crash or restart, page manually with a Cursor (it's Serialize/Deserialize, so you can checkpoint it):

# async fn run(client: matomo::reqwest::MatomoClient) -> Result<(), Box<dyn std::error::Error>> {
use matomo::{Cursor, Date, Limit, Period};

let mut cursor = Some(Cursor::new(
    1,
    Period::Day(Date::Yesterday),
    Limit::count(1000).unwrap(),
    None,
)?);

while let Some(c) = cursor {
    let (visits, next) = client.live().next_page(&c).await?;
    // ... persist `visits` and the `next` cursor ...
    cursor = next; // `None` once an empty page is reached
}
# Ok(())
# }

Date ranges

Matomo is picky about range grammar, so the types only let you express what it accepts: a single rolling keyword, or an absolute start with an absolute/today/yesterday end. A keyword start like last7,today simply isn't representable.

use matomo::{DateRange, RangeEnd};

DateRange::LastN(7);                                  // last7
DateRange::PreviousN(30);                             // previous30
DateRange::ymd((2024, 1, 1), (2024, 6, 30));          // 2024-01-01,2024-06-30
DateRange::Between { from: (2024, 1, 1), to: RangeEnd::Today };

The escape hatch

Not every method is typed yet. For anything else, call it directly and deserialize into your own type (or just a serde_json::Value):

# async fn run(client: matomo::reqwest::MatomoClient) -> Result<(), Box<dyn std::error::Error>> {
use matomo::Params;

let params = Params::new().id_site(1u32).set("period", "day").set("date", "today");
let value: serde_json::Value = client.call("Goals.get", &params).await?;
# Ok(())
# }

call_raw returns the response bytes untouched if you'd rather stream-parse a large payload yourself.

Preflight

On the first call, the client checks the instance version and confirms the reports it depends on are actually exposed, failing early with a clear error instead of a confusing decode failure later. Opt out with MatomoClient::builder().skip_preflight().

Error handling

Matomo signals failures as HTTP 200 with a {"result":"error",...} body, which the client turns into a structured Error::Api — classified into an ApiErrorKind (best-effort, from the message text) and tagged with the method that failed, so a mid-export error is debuggable:

use matomo::{ApiErrorKind, Date, Error, Period};

# async fn run(client: matomo::reqwest::MatomoClient) {
match client.visits_summary().get(1u32, Period::Day(Date::Today), None).await {
    Ok(summary) => println!("{} visits", summary.nb_visits),
    Err(Error::Api { kind: ApiErrorKind::Auth, message, .. }) => eprintln!("bad token: {message}"),
    Err(Error::Api { kind: ApiErrorKind::RateLimited, .. }) => { /* back off and retry */ }
    Err(Error::Api { method, message, .. }) => eprintln!("{method} failed: {message}"),
    Err(Error::NonJsonBody { body, .. }) => eprintln!("server returned non-JSON: {body}"),
    Err(e) => eprintln!("{e}"),
}
# }

Bring your own HTTP client

The reqwest transport is optional. If you'd rather not pull it in — to reuse an existing client, or run on a different stack — implement the Client trait over neutral http/bytes types and drive endpoints through Query:

use bytes::Bytes;
use http::{Request, Response};
use matomo::{Client, Endpoint, Query};

struct MyTransport {
    base_url: String,      // e.g. "https://stats.example.com/"
    token: String,
    http: reqwest::Client, // any version, or any HTTP client
}

#[derive(Debug, thiserror::Error)]
enum MyError {
    #[error(transparent)]
    Http(#[from] http::Error),
    #[error(transparent)]
    Request(#[from] reqwest::Error),
}

impl Client for MyTransport {
    type Error = MyError;

    async fn execute(&self, req: Request<Bytes>) -> Result<Response<Bytes>, Self::Error> {
        // The core builds a POST to `/index.php` with the form body
        // (module/method/format/params). You resolve the base URL and apply auth.
        let rsp = self
            .http
            .post(format!("{}index.php", self.base_url))
            .bearer_auth(&self.token) // or append `token_auth=<token>` to the body
            .header(http::header::CONTENT_TYPE, "application/x-www-form-urlencoded")
            .body(req.into_body())
            .send()
            .await?
            .error_for_status()?;

        let mut out = Response::builder().status(rsp.status());
        for (k, v) in rsp.headers() {
            out = out.header(k, v.clone());
        }
        Ok(out.body(rsp.bytes().await?)?)
    }
}

// Then drive any endpoint through the trait:
# async fn run(client: MyTransport) {
let version = matomo::endpoints::ApiGetMatomoVersion.execute(&client).await;
# }

To reuse a pre-configured reqwest::Client (custom TLS, proxy, timeouts) without writing a transport, pass it in: MatomoClient::with_reqwest_client(base_url, auth, http).

A few caveats

  • Matomo's JSON is loose — numbers arrive as strings, empty results flip between [] and {}, fields come and go between releases. The models handle the known cases and deliberately don't reject unknown fields, but if you hit a shape that doesn't deserialize, the escape hatch is your friend (and a bug report is welcome).
  • The Visit model keeps the data-bearing fields and drops Matomo's presentation fields (*Pretty, icons, flags). Use call_raw if you need the raw lot.
  • Aggregate metrics like unique visitors can't be derived from raw visits — use VisitsSummary.get for those.

Testing

Unit tests run against mocked HTTP — no network, no credentials:

cargo test

The integration test hits a real Matomo instance and is #[ignore]d by default. Point it at an instance with a read-only token:

MATOMO_URL=https://stats.example.com/ \
MATOMO_TOKEN=your-token \
MATOMO_IDSITE=1 \
cargo test --test live_integration -- --ignored

License

MIT OR Apache-2.0.