Only the tiles above and the trend chart support multiple selected years - the rest of this section is hidden until you narrow back down to a single year.
Month-to-date trend
Visitors
New transactions
| Order ID | Order date | Status | Country | City | Payment method | Product | Unit price | Currency | Total |
|---|
What this counts
Every subscription that hasn't ended yet - status active, on-hold, pending, or pending-cancel (WooCommerce's own definition of a "current" subscription), counted as of today.
This is broader than "Active Donations/Adoptions" above it, which only counts strictly active status. Because it can be reconstructed from each subscription's start/end dates, it's the one that gets a real year-over-year comparison.
Subscriptions
| Subscription | Start date | Product | Billing | Status | Total |
|---|
HeroRAT Adoptions first-time only
HeroDOG Adoptions first-time only
New (bars) vs Active (lines) per year
New adoptions by country (all time)
Adoption package mix
Churn rate by package type
Lost vs active-at-start of the selected period, per package.
Retention by signup cohort
iAdoption revenue
New vs Lost adoptions
HeroRAT Adoption
HeroDOG Adoption
Filter by category
Top sellers
Adoptions by animal
Top lost/cancelled animals (all time)
Not available with multiple years selected - narrow back down to a single year to see this.
Donations
Top donation products
Not available with multiple years selected - narrow back down to a single year to see this.
HeroGifts
Top HeroGift products
Monthly goals
Set a revenue target (EUR) for the current month and the next 11 - the current month also shows how much has been raised so far against its target. Set either the % (vs last year) or the Target (EUR) directly - the other one fills in automatically. Every figure here is always converted to real EUR, regardless of the currency mode selected elsewhere on the dashboard (e.g. Website Performance's own "Gross (unconverted)" mode will show a different, larger number for the same period - that's a raw mixed-currency sum, not real EUR).
| Month | LY (EUR) | % vs LY | Target (EUR) | Raised so far (EUR) | Progress | |
|---|---|---|---|---|---|---|
| Loading… | ||||||
Payments
Based on completed website donations this period.
By payment method
By payment type
Payment types - trend
Revenue this period, top 6 payment types by revenue.
| Payment type | Transactions | Avg | Total |
|---|
New vs Renewal by payment type
Which payment types are used for one-time gifts vs automatic recurring charges.
| Payment type | New transactions | New revenue | Renewal transactions | Renewal revenue |
|---|
By device
Revenue by payment type, split by the device it was made on.
KPIs
Global Revenue (Gross)
Website + Store + Old Web. (PayPal Old Web., Stripe US/EU Old Web.) combined.
By source (this month)
Click a slice (or a row) to filter Top Products by that channel.
| Channel | Amount | Share |
|---|
Top Products
| Product | Qty | Revenue |
|---|
GlobalGiving by Donor Currency
Every GlobalGiving donation is already converted to USD before this dashboard ever sees it (see the Technical page's Integrations note) - this breaks that USD total back down by the currency the donor actually gave in.
| Donor Currency | Donations | Gross (USD) | Fees (USD) | Disbursed (USD) |
|---|
Active Subscriptions
Subscriber Growth & Churn
New: a subscription's first-ever real charge (exact for every channel). Ended: APOPO.ORG uses its own real cancellation date (exact); PayPal/Stripe EU/Stripe US Old Web. infer it from a subscription's last real charge plus its own billing cycle's grace window lapsing (estimated). Click a bar for the list.
Click a bar above to see the subscriptions
| Channel | Subscription ID | Date | Amount | Status | Product |
|---|
Balance
KPIs
This month by currency
| Currency | Total (native) | Total |
|---|
Payments by Platform (Gross)
Every completed transaction on this platform, current period vs the same period last year.
Fees & Net Revenue
What Stripe/PayPal actually charged in fees this period, read directly from each order's own processor data (not a separate Stripe/PayPal connection).
| Gateway | Transactions | Revenue | Fees | Net | Fee rate |
|---|
Payout & Transaction Reconciliation
Three channels: Stripe Global (actual bank payouts), PayPal Website (matched to a recorded donation), and PayPal Old Web. (the same shared PayPal account's unmatched old-site activity - full reconciliation pending the NAV API).
Stripe Global
| Payout date | Status | Amount | Orders covered | Matched revenue | Unmatched |
|---|
PayPal Website vs PayPal Old Web.
| Currency | Website transactions | Website amount | Old Web. transactions | Old Web. amount |
|---|
Net Revenue breakdown
How Global Revenue, fees, and Net Revenue for this period break down by source.
Website
| Gateway | Transactions | Revenue | Fees | Net |
|---|
Old Web.
| Source | Transactions | Revenue | Fees | Net |
|---|
Other Platforms
GlobalGiving, Maecenata, GDG - separate third-party platforms, not PayPal/Stripe activity on the old website.
| Source | Transactions | Revenue | Fees | Net |
|---|
Store
Store checks out through the same Stripe Global/PayPal Website accounts as the website above - shown separately here for visibility, and included in the Total below.
| Gateway | Transactions | Revenue | Fees | Net |
|---|
Total
Website + Old Web. + Other Platforms + Store.
| Revenue | Fees | Net |
|---|
Revenue by Channels
Waiting for the NAV API connection - once channel-level revenue is synced from NAV, this page will show the same breakdown as the current PowerBI report, per channel per month.
Revenue by Channels
Channel transactions
Amounts shown in each transaction's own original currency, not converted.
| Date↕ | ID↕ | Donor↕ | Product↕ | Amount↕ | Currency↕ | Fee↕ | Net↕ | Payment method↕ |
|---|
Integration Map
Money flow for the selected period - line thickness and labels scale with actual revenue.
Connected Integrations
Which payment source feeds which processor account - a reference map, not a live figure.
| Source | Feeds into | How it's identified |
|---|---|---|
| apopo.org website (donations) | Stripe Global, PayPal Website | WooCommerce order - payment_method |
| Store (merchandise) | Stripe Global, PayPal Website | Separate WooCommerce site, same processor accounts - payment_method |
| Old Site (Old Web.) | PayPal Old Web., Stripe US Old Web., Stripe EU Old Web. | PayPal transaction with no matching WooCommerce order; or a Stripe charge linked to a Stripe-native subscription, description "Invoice ...", no third-party app |
| Donorbox | Stripe US Old Web. | Stripe Connect application id + donorbox_* charge metadata - confirmed US-account-only, none found on EU |
| Tiltify | Stripe US Old Web. | Charge metadata originator: tiltify, description "Tiltify - [campaign id] - slug" - confirmed US-account-only, none found on EU |
| Other / unidentified | Stripe US Old Web., Stripe EU Old Web. | Doesn't match a known fingerprint yet |
Stripe US / EU Old Web. by source
Live breakdown for the selected period - Donorbox and any other platform integration's actual weight within the two Old Web. Stripe accounts.
Live Transactions
Every completed transaction across Website, Store, and Old Web., most recent first - native currency, no FX conversion. Refreshes automatically every 20 seconds while this page is open.
| When | Type | Source | Gateway | Amount |
|---|
Not available with multiple years selected - narrow back down to a single year to see this.
Total revenue (Gross)
By source
Where store buyers came from this period, ranked by revenue - a real referring domain (e.g. apopo.org, google.com) when one was captured, falling back to a general category (organic search, direct, etc.) when it wasn't.
| Source | Orders | Average | Total |
|---|
Open Orders & Shipping Status
| Order | Date | Status | Total | Location | Sent to Gelato | Shipped | Tracking |
|---|
Merchandise
Top merchandise products
Donor Insights
First-Time Donors vs Average First Giving
Daily first-time donor count (bars, left axis) against their average first gift in EUR (line, right axis), for the selected period.
Newsletter Opt-IN Impact
Compares first-time donors from this period who signed up for the newsletter at checkout against those who didn't (website only). "Average first giving" only counts this period. "Returned since" looks at their entire donation history, not just this period, since a donor who just joined hasn't had a chance to give again yet.
| Group | First-time donors | Average first giving | Returned since |
|---|
Mailchimp Newsletter Audience
Why is it changing?Loading...
Campaigns Sent - This Period vs Same Period Last Year
Loading...
Mailchimp-Attributed Revenue Trend
Orders (bars) and revenue (line) Mailchimp associates with a campaign - mostly by matching the recipient's email against orders WooCommerce reports, NOT verified click-through (spot-checked live: recurring subscription renewals show up here with zero click/landing-site evidence, tagged just because that donor happened to receive the campaign). Treat this as "orders from people on this campaign's list," not "orders this campaign caused." Not this dashboard's own order data. Last 15 months.
This period
| Date | Subject | Recipients | Orders | Revenue |
|---|
Same period last year
| Date | Subject | Recipients | Orders | Revenue |
|---|
Mailchimp Audience - What's Driving the Change
Every month's net change is really three separate things happening at once: new signups (green, up), unsubscribes (red, down) and "cleaned" addresses - hard bounces Mailchimp permanently removes (orange, down). A declining total usually means unsubscribes/cleans are outpacing signups, not that signups stopped. Compare against the send log below: a burst of full-list emails in a short window (especially urgency-style fundraising appeals) is the most common cause of an unsubscribe spike.
Recent Sends (last 90 days)
Full-list newsletters typically reach the entire ~34k audience; smaller ones are targeted segments (e.g. individual HeroRAT adoption updates) and rarely drive unsubscribes on their own - it's a cluster of large sends close together that usually does.
| Date | Subject | Recipients |
|---|
Campaign
By Product
Which of this campaign's attributed orders were for which product.
| Product | Orders | Revenue |
|---|
Orders
Loading...
| Donor | Amount | Product (this order) | Type | Customer's Total Orders (all-time, any product) | Click Evidence |
|---|
Donors Overview
Based on new conversions this period (each donor/customer's very first order, by billing email) - not repeat gifts, subscription renewals, or repeat purchases - since this page is about where donors come from, not where every transaction happens. Location and device come from checkout data; not every order carries every field, so counts may not sum to the transaction total.
New vs returning donors
Grouped by billing email, server-side only - no email or name is ever shown here or anywhere else in this dashboard.
By Donors Location (Top 10)
By device
How they found us
By country - trend
Daily revenue this period (EUR), top 6 countries by revenue.
| Country | Donations | Avg | Total |
|---|
By device - trend
Daily revenue this period (EUR).
| Device | Donations | Avg | Total |
|---|
By Donors Location - full list
Revenue Overview
Based on completed orders this period.
Revenue by category
Donors by giving range
How much of this period's revenue came from each giving tier - a few big gifts can outweigh many small ones. Website only.
Average Giving size trend
Daily donation count (bars) vs average amount per donation (line) - a shrinking average can hide behind a steady donor count. Website + manual entries, excludes Adoptions/HeroGifts.
Donation Heatmap - Day and time
New donations (not renewals) mapped by the day and time they were made (EUR, site-local time). Website only.
Donation Heatmap by country
Each country's own day/time pattern (new donations only, EUR). Pick a zone, then check the countries to show.
Where donations come from
Shaded by total raised this period (new donations only, EUR). Click a country to see its best time to launch a campaign.
Best time to launch a campaign - Worldwide
Ranked by past donation activity in this slot, across every country - always based on all-time history, regardless of the period filter above. Click a country on the map above to see its own best times instead.
| # | Day | Time | Donations | Raised |
|---|---|---|---|---|
| Loading… | ||||
Instagram Posts
Loading...
Advanced · API Keys
Lets an external system (e.g. Odoo) pull one channel's data directly over REST, without ever sharing the dashboard's own login. Each key only ever unlocks the one channel it was created for.
| Name | Channel | Key | Created | Last used | Status |
|---|
Overview
Import from Platform
Upload a donor/revenue report exported from an external platform - the system recognizes the file's format based on the platform you select below and imports it into the matching table. Safe to re-upload the same or an updated file; matching donations are updated in place, never duplicated.
Recent Uploads
Upload Log
Every platform report ever uploaded via Manual Entries, most recent first. Re-uploading the same file updates its entry in place rather than adding a new one.
| File | Platform | Records | Date range | Uploaded | Status |
|---|
Alerts
Integrations
Which external services are connected and when they last synced. API keys are never shown here - configured in the server's environment only.
Documentation
Reference docs for the external-facing REST API (Settings > API Keys) - hand this to whoever's building an integration against it (Odoo, currently).
Public API - Donation Transactions
External-facing REST API for pulling donation/transaction data out of this dashboard into Odoo (or any other system). This is a separate authentication surface from the dashboard's own login - it uses its own API keys, never the dashboard session/cookie. Implements Odoo/Innolabs' openapi.yaml "APOPO Donation Transactions API" contract (2026-09).
Base URL
https://stats.apopo.org/api/v1
Authentication
Every request requires an API key, either as the spec's own header or as a Bearer token (both accepted):
X-API-Key: <your-api-key>
# or
Authorization: Bearer <your-api-key>
API keys are created under Settings > API Keys. Each key is generated once, shown in full exactly one time at creation, and only a hash is stored afterward - if it's lost, a new key must be created (the old one can be revoked). A key is locked to exactly one channel at creation time; there is no way to request a different channel's data with someone else's key, and no channel query parameter is read from the request.
Channels
| Channel key | Label | Source | platform_code |
|---|---|---|---|
globalgiving | GlobalGiving | Manually uploaded monthly donor reports | GG |
maecenata | Maecenata | Manually uploaded monthly donor reports (German TGE platform) | MAEC |
gdg | GDG | Manually uploaded monthly project fund statements (Global Development Group) | GDG |
paypal | PayPal | Automatically synced - transactions with no matching website order ("PayPal Old Web.") | PAYPAL |
stripe-us | Stripe US Legacy | Automatically synced - separate, disconnected-from-website Stripe account | STRIPEUS |
stripe-eu | Stripe EU Legacy | Automatically synced - separate, disconnected-from-website Stripe account | STRIPEEU |
donorbox | Donorbox | Automatically synced - a real, still-active platform that settles through the Stripe US account, split out from Stripe US Legacy so it isn't buried under it | DONORBOX |
globalgiving/maecenata/gdg, data only appears once a staff member has clicked "Approve for Odoo" on that upload in the Upload Log - a deliberate human review gate. paypal/stripe-us/stripe-eu have no such gate - already-synced data is available immediately once the API key exists.
Endpoint: GET /transactions
Returns one channel's transactions, cursor-paginated (not offset-based - stays correct and resumable even if rows are inserted or reordered mid-fetch, which matters at GlobalGiving's 23,000+ row scale).
| Param | Type | Default | Notes |
|---|---|---|---|
since | date-time | - | Only rows created/modified at or after this timestamp. Omit for a full backfill. |
cursor | string | - | Opaque - pass back the previous response's next_cursor to get the next page. |
limit | integer | 200 | Max 1000 per request |
curl "https://stats.apopo.org/api/v1/transactions?limit=200" \
-H "X-API-Key: <your-api-key>"
Response envelope:
{
"data": [ ... one Transaction object per row, see below ... ],
"next_cursor": "6120002"
}
next_cursor is null on the last page.
Endpoint: GET /transactions/{transaction_id}
Fetches a single transaction by its transaction_id (must belong to the requesting key's own channel). 404 if not found.
Transaction fields
| Field | Type | Meaning |
|---|---|---|
transaction_id | string | Permanent, unique, never reused - "<platform_code>-<native_id>", e.g. GG-6119883. GDG has no per-donation id (monthly project totals) so it uses the spec's own aggregate fallback: "GDG-<yyyy-mm>-batch-<project_code>", e.g. GDG-2026-07-batch-J932N. |
donor_id, donor_name | string | omitted | Only present where the source has one. Maecenata has both; GlobalGiving has name only; PayPal has donor_name (no donor_id - PayPal's own payer id isn't currently mapped); GDG/Stripe Legacy have neither. |
donor_email, address, city, postal_code, country | string | omitted | Present only when consent_contact is true. No placeholder/synthetic email is ever sent - omitted entirely when there's no real address, per Odoo's own hard rule against synthetic addresses. |
consent_contact | boolean | True only when a genuine (non-placeholder) donor email is on file for this row. |
date | date-time | When the transaction happened (or GDG's reporting period close). |
date_modified | date-time | When this row was last written on our side - advances on re-upload/re-sync so since picks up corrections. |
status | string | omitted | completed for every manually-reported row (a report row only exists for money already received), Stripe Legacy charges, and every PayPal row confirmed as raw code S (Success - the only code seen across all 35,000+ real rows); refunded for Stripe Legacy refund rows (a real, separate signal in that source). Any other PayPal code, if one is ever seen, is left omitted rather than mapped without evidence. |
is_gift, recipient_* | boolean / omitted | Always false/omitted - no source currently distinguishes a gift donation. |
buyer_opted_in | boolean | Newsletter/marketing consent at time of donation. Always false currently - no source captures real opt-in, and GlobalGiving/Maecenata's own terms prohibit it. |
payment_transaction_id, refunded_amount | omitted | No source currently has a processor reference distinct from its own transaction id, or links a refund back to its original transaction. |
project_code | string | GlobalGiving's numeric project id, Maecenata's/GDG's own project code. Null/omitted for PayPal/Stripe Legacy unless a real SKU has been entered in Technical > Product SKU Mapping - confirmed with Odoo (2026-09) that omitting is correct here, their own product resolution has its own fallback for a missing code. |
project_name | string | omitted | Human-readable label, never used for mapping. GlobalGiving's/GDG's own project name. PayPal gets this from two one-time historical enrichment passes against NAV's own exports (not a live sync): first the per-transaction PayPal export (real project/animal info PayPal's own API never exposes, matched on PayPal's transaction id), then overridden/refined using NAV's separate subscription master table (cleaner, controlled vocabulary - 19 known values - vs free-text drift in the transaction-level export). Some subscriptions genuinely fund more than one animal at once, e.g. "BARAKA, MAGAWA" - a comma-joined list, not an error. Only populated where NAV's export and our own synced history both cover the same transaction (~82,000 rows as of 2026-09-16) - most PayPal rows still have no project_name. |
source | string | omitted | PayPal only - PayPal's own free-text payment description (transaction_subject, e.g. "Rat adoption: Recurring Donation to Apopo."). Omitted for every other channel. |
currency, gross_amount | string / number | ISO 4217 code and the donor-facing amount before fees, in that currency (native, not converted). |
fees | array | omitted | Omitted entirely when there's no fee (never sent as an explicit zero). Entries: {type: "processing", amount} (processor fee) and/or {type: "platform_support", amount} (the platform's own cut). |
net_amount | number | omitted | What actually lands after fees. Omitted when equal to gross_amount (no fees) - except GDG, which always includes its own real reported payout figure (can differ from gross-minus-fees since GDG holds a running balance). |
payment_method | string | omitted | paypal/stripe for those two channels, Maecenata's own recorded method (e.g. "Direct Debit"), omitted for GlobalGiving/GDG. |
subscription_id | string | omitted | PayPal only - PayPal's own recurring-profile/billing-agreement id (e.g. I-W8BC91LWVVL8), set when the transaction's own reference type (RP/PAP) or event code (T0002/T0003 - confirmed to pair 1:1 with the reference type on every real row seen) indicates a recurring PayPal payment. Omitted for every other channel and for one-off PayPal transactions. |
subscription_status | string | omitted | PayPal RP-type only (subscription_id starting with I-) - active/cancelled/pending, sourced from a periodic background sync against PayPal's separate Subscriptions API (not Transaction Search), cached in paypal_subscriptions and refreshed on a 24-hour TTL, so it can lag PayPal's real state by up to that long. PAP-type transactions (subscription_id starting with B-, older Reference Transaction billing agreements) never get a status - confirmed live that PayPal's Subscriptions API doesn't recognize those ids as a resource at all. PayPal's own SUSPENDED state is also omitted rather than force-mapped onto this 3-value enum. |
subscription_cycles_completed, subscription_start_date | number / date-time - EXTRA, non-spec | Not part of Odoo's openapi.yaml Transaction schema - a deliberate deviation from the exact agreed contract, sent as additional JSON properties alongside the defined ones (valid JSON, but flag to Odoo before assuming their importer keeps them). Same PayPal RP-type-only / cache-TTL caveats as subscription_status above. |
Errors
| Status | Meaning |
|---|---|
| 401 | Missing, invalid, or revoked API key. |
| 404 | No transaction with that id (single-record endpoint). |
| 500 | The channel this key was created for no longer exists. |
{ "error": "Invalid or revoked API key" }
Example response (illustrative, not real donor data)
{
"data": [
{
"transaction_id": "MAEC-154708392058",
"donor_id": "1512259",
"donor_name": "Jane Example",
"donor_email": "jane.example@example.com",
"consent_contact": true,
"address": "Musterstraße 1",
"city": "Berlin",
"postal_code": "12345",
"country": "Deutschland",
"date": "2026-04-06T00:00:00.000Z",
"date_modified": "2026-09-14T14:12:32.916Z",
"status": "completed",
"is_gift": false,
"buyer_opted_in": false,
"project_code": "516",
"currency": "EUR",
"gross_amount": 15,
"fees": [
{ "type": "processing", "amount": 0.24 },
{ "type": "platform_support", "amount": 0.75 }
],
"net_amount": 14.01,
"payment_method": "Direct Debit"
}
],
"next_cursor": "156941994318"
}
Data Migration
One-time historical recovery and enrichment work, run against APOPO's NAV export (not a live sync - see Integrations above for what runs automatically).
Tech Stack
What this dashboard is built and hosted with.
Backend
Frontend
Infrastructure
Data sources
Data Coverage
How far back data goes and how fresh it is, per channel. Auto-synced channels flag stale after 4 days without a new row; GlobalGiving only updates when a report is uploaded via Manual Entries, so longer gaps there are normal.
Product SKU Mapping
Product Category Mapping
PayPal/Stripe Legacy have no category of their own the way APOPO.ORG does (Adoptions/HeroGifts/Donations) - each NAV product code below has been auto-matched against APOPO.ORG's own real product names where possible; anything that didn't confidently match either real list shows as "Uncategorized" rather than being guessed. Correct any row's category below - once set by hand it's never overwritten by a later automatic pass.
PayPal Review
PayPal payments held back from the Odoo API. Check whether each one belongs to a website order and link it, or keep it as a PayPal donation.
Mapping
Overview of every channel / product / category
1. Product Code Drift
A subscription's own product code changes between different renewal dates (e.g. a relabeled adoption). Usually benign, worth an occasional glance.
2. Same-Day Multi-Product Bundles
Multiple distinct products charged the same day under one reference ID - a real billing agreement can bundle more than one purchase into one checkout. Each needs a manual per-transaction product code correction (see the Jane/Baraka/Tamasha case).
3. Suspicious Single-Charge Subscriptions
Only one payment ever, with no known billing cadence - could be a genuine one-time gift (PayPal's PAP checkout doesn't always mean recurring), or just a brand new subscriber too young for a second payment yet. Verify against PayPal/Stripe directly before changing anything.
4. Mixed Matched/Unmatched Transactions
Same PayPal reference ID with some transactions matched to a real WooCommerce order and some not - usually a genuine sync gap on the unmatched row(s), not a second subscriber.
5. Stale Subscription Status
Cached status hasn't been refreshed from PayPal/Stripe in over 48 hours - could be outdated (a subscription can change status any time, but this dashboard only re-checks periodically). Worth a live re-check before relying on it.