Every push we send you is measured, and every number we report tells you where it came from. A guide for Associates to reading delivery rate, asset coverage, and latency — and to knowing exactly how much to trust each one.
When you subscribe to Webhook Push, you are buying a promise that is unusually specific: we will send you prices for the assets you selected, at the interval your tier guarantees, over the transport you chose, and we will keep doing it without you asking. Most of our engineering effort goes into keeping that promise. This document is about the other half of the work — proving we kept it, in numbers you can audit.
That distinction matters more than it might sound. A delivery system that reports on itself honestly is rarer than it should be. It is very easy to build a dashboard that shows a reassuring green number, because the easiest metrics to compute are the ones that flatter the system computing them. We have deliberately gone the other direction. Several of the numbers in your Delivery Reports are designed to make our own shortfalls visible, including one that will show you a market limitation we cannot engineer our way out of.
None of this is an add-on. There is no reporting tier, no per-query charge, and no entitlement flag to buy. Ownership of a webhook subscription is the only gate — if you are an Associate, this is already switched on for you.
/price requests your key has made — that is the Listener Reporting Service, documented in LRS Report Management. The two are separate systems with separate storage and separate gating. LRS requires either a premium tier or the paid add-on; Delivery Reports do not.
Sections 2 through 4 explain the machinery — what we record, how we decide what constitutes a single push, and how ten million rows become an instant answer. Sections 5 through 8 define each metric precisely, including the reasoning behind definitions that could reasonably have gone another way. Section 9 is the practical reference: where to click, what to call, what every field means. Sections 10 through 12 cover language, freshness, and cost.
If you only read one section, read 6.3. It explains the single field that separates a number you can act on from a number you should treat as an estimate — and it is the field we are proudest of.
Everything in your Delivery Reports is derived from one table in Aurora: webhook_delivery_log. Understanding its shape explains almost every design decision that follows, so it is worth one careful paragraph.
We write one row per asset, per transport, per push. Not one row per push. Not one row per asset. One row for every combination. If you are on Webhook Standard with 30 assets configured and delivery_method=both, a single delivery cycle writes 60 rows — 30 for the HTTPS leg and 30 for the UDP leg.
That granularity is a deliberate choice with a real cost. It means an Enterprise subscriber at 60 assets, a 3-second interval, and both transports generates on the order of 3.5 million rows per day. We accepted that cost because the alternative — recording one summary row per push — would make it impossible to answer the question Associates actually ask, which is never "did the push go out" but almost always "why is SOL missing from my feed?" You cannot answer a per-asset question from per-push data. So we store per-asset data, and we built the rest of the system to make that affordable.
| Column | What it records |
|---|---|
subscription_id | Your subscription. Every query is scoped to this, always. |
asset | The asset symbol, normalized to uppercase — BTC, ETH, SOL. |
price | The exact price value we put on the wire for that asset in that push. |
source | Which of the 6 exchanges supplied the price. Useful when one venue goes thin. |
delivery_method | The transport of this leg — udp or https. Never both; see 3.3. |
status | Exactly two values: delivered or failed. There is no third state. |
latency_ms | How long the send took, in whole milliseconds. See section 8 for precisely what this clock covers. |
attempt | Which attempt succeeded or gave up. Always 1 for UDP; 1 through 4 for HTTPS, which retries 3 times. |
error | The failure reason when there is one, and empty when there is not. |
sequence_number | A counter that increments per push. Read section 3 before you rely on this — it is not what it appears to be. |
configured_assets | How many assets you had configured at the moment of that push. The most important column in the table, and the newest. Section 6.2 is entirely about it. |
created_at | Server-side timestamp of the write. |
delivered and failed. They are not success and failure. If you script against this data and filter on status != 'success', every row you own will look like a failure and you will conclude you are having an outage. We know because a diagnostic query of ours did exactly that, and it briefly manufactured an incident that did not exist. Check an enum's real values before you report on them — ours are documented here precisely so you do not have to guess.
Raw delivery rows are kept for 33 days and then removed, oldest first, by a scheduled job inside Aurora that runs nightly at 2:30 AM Eastern. 33 days is 11 × 3, and multiples of 3 are a house convention throughout this infrastructure — you will notice it in our retention windows, batch sizes, memory allocations, and tier intervals. This window applies the same way to every subscriber on this delivery engine, whether you are a paid Associate or a free-tier Partner.
Two things follow from that window, and both are good news:
Here is a question that sounds trivial and is not: given a pile of asset rows, which of them belong to the same push?
Every number in your report depends on the answer. Delivery rate is successful pushes over total pushes. Asset coverage is assets per push. Get the grouping wrong and every metric downstream is wrong — not slightly wrong, but wrong in ways that look plausible. We know the shape of that failure precisely, because we shipped it.
Each push carries a sequence_number, and the obvious move is to group by it. That is what we did originally, and it is wrong for a reason worth understanding.
The sequence counter lives in memory inside the container that is delivering to you, and it resets to 1 whenever that container restarts. On the wire this is exactly right — you receive a clean, monotonically increasing sequence from your delivery loop, which is what lets you detect a gap. In a database spanning days and deployments, it is a disaster: after six deploys in one day, six completely unrelated pushes all carry sequence_number = 1.
Grouping by that number alone merged pushes that were hours apart. One observed group held 96 asset rows spanning four separate minutes. The consequences were not subtle:
The instinctive fix is to group by time instead — rows close together are one push. That fails too, and it fails in the opposite direction. During a rolling deploy, two containers briefly deliver to you at once, each with its own counter. Grouped by time alone, those two concurrent pushes merge into one, and we measured a single "push" containing 222 assets for a subscription configured with 30.
A push is a set of rows that share a sequence number and are time-adjacent within 1500 milliseconds. Both conditions, together. Either one alone is demonstrably broken; combined, neither failure mode is possible.
| Grouping rule | Result on real production data | Verdict |
|---|---|---|
| Sequence number alone | Average 37.48 assets per push against a 30-asset cap | Inflated — impossible coverage, masked failures |
| Time adjacency alone | One push containing 222 assets on a 30-asset subscription | Merges concurrent producers during a deploy |
| Both, combined | Average 27.45, maximum in any push exactly 30 | Correct — matches the real configured cap exactly |
That third row is the confirmation that mattered. The maximum assets in any single push came out at exactly the subscription's configured limit — not near it, exactly it. A grouping rule that produces an impossible maximum is broken; one that lands precisely on the real ceiling is measuring something true.
flowchart TB
subgraph Rows["Raw asset rows in webhook_delivery_log"]
R1["seq=1 · BTC · 09:00:00.000"]
R2["seq=1 · ETH · 09:00:00.005"]
R3["seq=1 · SOL · 09:00:00.011"]
R4["seq=1 · BTC · 14:22:07.000"]
R5["seq=1 · ETH · 14:22:07.006"]
end
Rows --> Q{"Same sequence number?"}
Q -->|"no"| SEP["Different pushes"]
Q -->|"yes"| T{"Gap under 1500ms
from previous row?"}
T -->|"yes"| SAME["Same push"]
T -->|"no"| NEW["New push — counter reset
after a container restart"]
SAME --> P1["Push A — 3 assets
09:00:00"]
NEW --> P2["Push B — 2 assets
14:22:07"]
P1 --> OK["Coverage, delivery rate,
and latency computed per push"]
P2 --> OK
style P1 fill:#064e3b,stroke:#10b981,color:#d1fae5
style P2 fill:#064e3b,stroke:#10b981,color:#d1fae5
style OK fill:#1e3a5f,stroke:#60a5fa,color:#dbeafe
style NEW fill:#7c2d12,stroke:#FF9900,color:#fed7aa
If you subscribe with delivery_method=both, one delivery cycle produces two push events in your reports — one for HTTPS, one for UDP. We do not merge them, and that is a feature rather than an accounting artifact.
The reason is that your two transports can fail completely independently, and a merged number would hide it. Here is a real subscription from our own test fleet, on a single day:
| Transport | Push events | Delivered | Delivery rate |
|---|---|---|---|
udp | 3,326 | 3,326 | 100.00% |
https | 3,323 | 0 | 0.00% |
That subscription's HTTPS endpoint was deliberately pointed at a hostname that does not resolve, while its UDP endpoint was live. Reported per transport, the numbers tell you exactly what is wrong in one glance: your UDP feed is perfect, your HTTPS endpoint is unreachable, go check your URL. Merged into one figure, the same day would have read as roughly 50% delivery — a number that describes nothing real, points at no action, and would have you suspecting our infrastructure rather than your endpoint.
by_delivery_method object — check that object first whenever an overall rate looks worse than you expected. Half your traffic being perfect and half being broken is a very different problem from everything being mildly degraded.
Your Delivery Reports are fast, and they stay fast no matter how much history accumulates. That is not because the delivery log is small — it is currently over ten million rows and more than two gigabytes. It is because your reports never read it.
Between the raw log and your report sits webhook_delivery_daily, a rollup table holding one row per subscription, per day, per transport. A month of history is about 60 rows for you. Querying it is trivially cheap, and the cost does not grow as the raw log does.
flowchart LR
subgraph Deliver["BeastWebhook — delivery engine"]
LOOP["Per-subscription
delivery loop"]
CACHE["WebSocket price cache
6 exchanges"]
LOOP --> CACHE
end
subgraph Wire["Your endpoints"]
HTTPS["HTTPS
signed, 3 retries"]
UDP["UDP
fire and forget"]
end
subgraph Store["Aurora PostgreSQL"]
RAW["webhook_delivery_log
one row per asset per transport
33-day retention"]
DAILY["webhook_delivery_daily
one row per day per transport
permanent"]
end
subgraph Refresh["Rollup refresh"]
CRON["pg_cron
every 15 minutes
trailing 2 days"]
NIGHT["BeastReconciler
nightly, trailing 3 days
all subscriptions"]
BTN["Refresh Now
on demand, today only"]
end
subgraph Read["What you read"]
DASH["Account Dashboard
Delivery Reports panel"]
API["GET /webhook/reports/*
JSON · CSV · TSV · Text"]
end
LOOP --> HTTPS
LOOP --> UDP
HTTPS --> RAW
UDP --> RAW
RAW --> CRON
RAW --> NIGHT
RAW --> BTN
CRON --> DAILY
NIGHT --> DAILY
BTN --> DAILY
DAILY --> DASH
DAILY --> API
style RAW fill:#7c2d12,stroke:#FF9900,color:#fed7aa
style DAILY fill:#064e3b,stroke:#10b981,color:#d1fae5
style DASH fill:#1e3a5f,stroke:#60a5fa,color:#dbeafe
style API fill:#1e3a5f,stroke:#60a5fa,color:#dbeafe
The refresh groups your raw rows into pushes using the rule from section 3, then aggregates those pushes into one row per transport per day. Days are bucketed in America/New_York time, so a "day" in your report is an Eastern calendar day rather than a UTC one.
| Stored value | How it is computed |
|---|---|
push_events | Count of distinct pushes that day |
successful_events / failed_events | Pushes that delivered, and pushes that did not |
asset_rows | Total asset rows across all pushes — the raw volume |
avg_assets_per_push | Average assets carried per push. The numerator of coverage. |
avg_configured_per_push | Push-weighted average of the recorded configured count. The denominator of coverage, when available. |
typical_assets_per_push | The statistical mode of assets per push — the configuration the day mostly ran under |
distinct_assets | The largest single push that day, in distinct assets |
min_coverage_pct | The worst single push that day, against its own configured count |
pushes_at_full_coverage | How many pushes delivered every asset they had configured |
avg_latency_ms / p95_latency_ms | Average and 95th percentile latency across delivered pushes |
last_refreshed | When this row was last recomputed |
Delivery rate is the simplest metric in the report, and it is worth being precise about its scope because the precision is the useful part.
delivery_rate_pct = successful_events / push_events × 100
It is computed per day, per transport, and again across a whole range in the summary. A push counts as successful when we got the payload onto the network to your endpoint.
For HTTPS, delivery rate is close to end-to-end truth. We sign the payload, POST it, and read your response. A 2xx means you accepted it. A 4xx means you rejected it and we stop immediately, because retrying a request your server has already refused is not resilience, it is noise. A 5xx or a connection failure gets retried 3 times with 1, 2, and 4 second backoff before we record a failure. So an HTTPS success genuinely means your server acknowledged the data.
For UDP, it cannot mean that, and we will not pretend otherwise. UDP is fire-and-forget by design — there is no acknowledgement in the protocol, which is exactly why it is the fastest option we offer and why latency-sensitive Associates choose it. A UDP success in your report means we wrote the datagram to the socket without error. If a router between us drops it, that packet is gone silently and no honest reporting system on our side could tell you.
both. If you need minimum latency and can tolerate occasional loss — which is the normal trade for a price feed, where the next update is seconds away — UDP is the right choice, and the sequence field in every payload lets you detect gaps yourself. What we will not do is show you a 100% UDP delivery rate and let you infer a guarantee the protocol cannot give.
A push that carried 8 of your 9 assets counts as delivered, not failed. That is deliberate, and the reasoning is worth following because it is the difference between two genuinely distinct signals.
A push is one payload per transport. It either reaches your endpoint or it does not — there is no mechanism by which 8 assets arrive and the 9th is lost in transit, because they travel together in the same message. So when only 8 assets appear, the 9th did not fail to be delivered; it had no fresh price at push time and was never in the payload at all.
We verified this holds in practice rather than assuming it: across 5,814 real pushes, we found zero with mixed status. A push's asset rows always agree, because the transport succeeds or fails as a whole.
This is why we report two separate metrics instead of collapsing them into one. Delivery rate answers "is the pipe working?" Asset coverage answers "is the pipe full?" A missing asset is a price-freshness and market-liquidity signal, not a delivery signal, and folding it into delivery rate would leave you unable to tell an endpoint problem from a thin order book.
Asset coverage answers the question that matters most: of the assets you selected and are paying for, how many actually arrive?
asset_coverage_pct = avg_assets_per_push / coverage_denominator × 100
Two numbers, one division. The numerator is straightforward. The denominator took three attempts to get right, produced two customer-visible defects along the way, and is the reason this document exists. Both halves are worth your attention.
avg_assets_per_push is the average number of assets carried per push that day. An asset appears in a push when our delivery engine can resolve a current price for it at that moment. When it cannot, the asset is simply absent from that push.
Price resolution has two tiers: the delivering container's own WebSocket cache, fed directly by live trades from 6 exchanges, and failing that a cluster-wide cache shared across every node. A price is eligible for webhook delivery if it is under 5 minutes old.
/price, you are asking "what is the price right now", and serving you a four-minute-old number would be a broken promise. Webhook delivery is a subscription at a cadence you chose — as slow as 60 seconds on Starter — so a genuinely illiquid pair whose last trade was three minutes ago is better disclosed than omitted. We measured this before choosing: across the prewarm pool, price freshness was 32% at a 60-second cutoff, 54% at 5 minutes, then flattened out. Widening further would have meant serving hour-old prices to inflate a coverage number, which is the opposite of the point.
So a missing asset means one of three things, all of them information rather than malfunction: the pair is thinly traded and had no recent trade, its exchange feed is quiet, or it is genuinely illiquid. Every asset in your picker is swappable at any time in your dashboard — if an asset shows persistently low coverage, that is the system telling you to trade it for a more liquid one.
Dividing by "how many assets you configured" sounds like reading one number off your subscription. It is not, and the trap is subtle: your report covers a period of time, but your subscription row only knows what you have configured right now.
Measure a whole day's average against this moment's configuration and you get a snapshot-versus-history mismatch. Our first two attempts both failed on it, in opposite directions:
| Attempt | Denominator used | What it reported | Why it broke |
|---|---|---|---|
| First | Assets configured right now | 124.87% and 303% | An Associate who ran 30 assets in the morning and 9 in the evening produced an honest 27.29 average divided by 9 |
| Second | Largest push seen that day | 10.13% for a day that truly ran near 91% | A brief 30-push burst at 108 assets inside an otherwise 9-asset day redefined the entire day by its ceiling |
| Third | The statistical mode | 91.17% — correct | Describes the configuration the day mostly operated under, so a short spike no longer rewrites it |
Both early failures were correct arithmetic on a guessed number. That is the instructive part: the mathematics was never wrong, the input was an inference, and no amount of careful division fixes a denominator that was reconstructed after the fact.
So we stopped inferring. Every push now records how many assets you had configured at the instant it was sent, in the configured_assets column. The denominator became a recorded fact instead of an archaeological reconstruction.
Two details make this trustworthy rather than merely present:
That second point is subtler than it looks. A recorded denominator derived even slightly differently from the delivered list would reintroduce the same class of defect one layer down — a denominator that never quite matches its own numerator. Sharing the definition is what closes it permanently.
The recorded value is a push-weighted average, not a single figure, and that is precisely what makes it correct on the hardest day: one where you changed your configuration partway through.
Suppose you ran 30 assets for half a day and 9 for the other half, at 90% coverage throughout. Delivered averages 17.55; configured averages 19.5; the quotient is exactly 90%. Because both terms are push-weighted means, dividing one by the other yields total delivered over total configured — the correct aggregate ratio, with no special-casing. A single-value denominator would have picked 30 or 9 and reported 58.5% or 195%.
Every daily row carries a coverage_basis field naming where its denominator came from, and a coverage_denominator field carrying the exact value the percentage was divided by.
| Basis | Meaning | How much to trust it |
|---|---|---|
recorded | Read from configured_assets, captured at push time | A fact. Act on it directly. |
typical | The mode of delivered asset counts that day | A good inference. Reliable for stable configurations. |
max | The largest push that day | A ceiling. Understates days with brief configuration spikes. |
configured | Your current configured count | Correct if you have not changed your selection during the range. |
none | Nothing meaningful to divide by | Coverage is not applicable — e.g. a single-asset filtered view. |
This field is the one we would point to if asked what distinguishes this reporting system. A metric that does not disclose its own provenance invites you to trust a reconstruction exactly as much as a measurement. The entire history of this number was numbers whose origin was unclear — so now the origin travels with the number, produced by the same function that produced the value, specifically so the two can never drift apart.
It also means you can audit us. If a coverage figure looks wrong, check the basis first: a recorded value that disagrees with your own logs is worth reporting to us as a bug. A max value that looks low on a day you changed tiers is the documented behavior described above, and the mode-based figure beside it is the better guide.
It would be easy to cap coverage at 100% so an impossible number can never appear again. We deliberately do not, and the reason is a principle worth stating: a wrong number that looks plausible is more dangerous than one that looks wrong.
The 124.87% reading was a gift. It was self-evidently impossible, so it got investigated within minutes and exposed a grouping defect that was also silently masking real delivery failures. Clamped to 100%, that same defect would have shown a perfectly reasonable figure and could have hidden for months.
So if you ever see coverage above 100% in your reports, please tell us. It means something is genuinely wrong, and we would very much rather hear it from you than have our own display quietly conceal it.
A single average coverage figure has a structural blind spot, and it is a serious one. Consider two days that both report 87.50% coverage:
Identical headline. Completely different problems. Day A is a market-liquidity ceiling — some of your assets are thinly traded and the average reflects it. Day B is an intermittent fault, and the two demand opposite responses: Day A means reconsider your asset selection, Day B means look at what happened at those specific moments.
An average cannot distinguish them. Not because ours is poorly implemented, but because averaging is by definition the operation that discards distribution. So we report two additional numbers alongside it, and they are designed to be read together.
| Field | What it tells you | Character |
|---|---|---|
pushes_at_full_coverage | How many pushes that day delivered every asset they had configured | Robust. A count — one bad push cannot move it meaningfully. |
min_coverage_pct | The worst single push that day, against its own configured count | An extreme, by design. Answers "how bad did it get". |
Applied to the two days above, the ambiguity disappears immediately. Day A reports few or no full pushes and a minimum close to its average. Day B reports mostly full pushes and a minimum of 30%. One glance, two different stories, two different actions.
min_coverage_pct is deliberately sensitive to a single event — a container restarting with a cold price cache will produce one thin push and land here, which is exactly what you want when hunting an incident and exactly what you do not want as a summary of the day. pushes_at_full_coverage is the opposite: stable, unmoved by one outlier, useless for finding the outlier. Together they read as a plain sentence — "2,900 of 2,983 pushes complete, worst push 33%" — and that sentence is actionable in a way that no single number is.
These two fields immediately taught us something about our own product that the average had been hiding. A subscription can report healthy average coverage — comfortably in the 90s — while delivering its complete configured set on only a minority of pushes. It consistently delivers nearly everything, and rarely everything.
Whether that matters depends entirely on what you are building. If you are computing a portfolio value or driving a display, near-complete every few seconds is fine. If your logic requires all N assets present in the same message before it can act, you should be watching pushes_at_full_coverage, not average coverage — and before these fields existed, there was no way for you to know the difference.
The honest framing is this: the constraint is real trading volume on real exchanges, not our engineering. As you configure more assets, the chance that every single one has a fresh price at the same instant falls, because you are necessarily reaching further down the liquidity curve. A tighter selection of well-traded assets will deliver complete pushes far more consistently than a broad selection reaching into thin markets. These fields let you measure that trade-off on your own subscription instead of taking our word for it.
This is the same measurement that set our pricing, so it is worth showing you the number it turns on. Across the 150+ assets we prewarm on 6 exchanges, here is how 24-hour trading volume actually distributes:
| 24-hour volume | Assets |
|---|---|
| $100M or more | 2 |
| $10M or more | 9 |
| $1M or more | 65 |
| $100K or more | 211 |
| $10K or more | 270 |
| under $10K | 87 |
Only about 65 assets trade above a million dollars a day. That is the real ceiling on how many assets can carry a genuinely fresh price at a 3-second cadence, and it is why both Professional and Enterprise cap at 60 rather than something larger.
We think that decision is worth explaining rather than just presenting, because the alternative was available and we rejected it. An earlier version of the Enterprise tier advertised 150 assets. It was never deliverable in full — measured delivery sat around 70 of 150 — for the simple reason that a 150-asset selection has to reach roughly 85 assets past the liquid universe. Rather than keep a number that reads well and cannot be met, we brought the ceiling down to what the market actually supports and made the top tier differentiate on speed instead: same 60 assets, pushed twice as often.
pushes_at_full_coverage to see whether your particular selection delivers completely. If it does not, the picker sorts by volume and every asset is swappable at any time.
coverage_basis is recorded. Where they cannot be computed they render as a dash rather than a zero — a zero would read as catastrophic failure, and "not measured" is the truth. In range summaries they are paired with pushes_coverage_measured, which states how many pushes the figures actually cover, so the pair reports its own scope rather than implying it spans everything.
Each day and transport reports two latency figures, both in milliseconds: avg_latency_ms and p95_latency_ms. Both are computed across delivered pushes only — a failed push's timing measures how long a failure took, which would quietly poison the number you actually care about.
| Transport | Clock starts | Clock stops | Includes |
|---|---|---|---|
https | Before we sign the payload | When your response is fully received | HMAC signing, connection, transfer, your server's processing time, and any retry backoff |
udp | Before the socket is opened | When the datagram is written | Socket setup and the write itself |
Two consequences follow, and both are deliberate:
Latency is recorded per push leg rather than per asset. Every asset row within one push carries the same value, because the payload travelled as a single message — there is no per-asset timing to record, and inventing one would be fiction.
Note that range summaries report an average but no p95. Percentiles cannot be meaningfully averaged across days — a p95 of p95s is not a p95 of anything — so rather than compute a number that would look authoritative and mean nothing, we omit it. For tail latency across a range, read the daily rows.
Two ways in, backed by the same data and the same arithmetic: the Account Dashboard for looking, the API for scripting. They cannot disagree, because they resolve every number through shared logic — a deliberate choice after we learned how easily two implementations of one metric drift into two different answers.
Sign in at api.cpmp-site.org/dashboard and open Delivery Reports. Login is passwordless — request a magic link, click it, you are in.
| Control | What it does |
|---|---|
| Start / End date | Bounds the range. Defaults to the trailing 30 days. |
| Method | Filter to HTTPS or UDP alone, or leave blank for both. |
| Asset | Narrow to one asset. Reads the raw log, so it is bounded to 33 days and reports no coverage — see the note below. |
| Run Report | Loads the summary and the day-by-day breakdown. |
| Refresh Now | Recomputes today's rollup immediately. Limited to once per 60 seconds. |
| JSON / CSV / TSV / Text | Export the current view. Same four formats the API offers — the dashboard gets no more and no fewer. |
There is also a separate Delivery Log panel for live activity: your 50 most recent pushes, expandable to the individual asset prices in each, with second-precision Eastern timestamps. Use Delivery Log for "what is happening right now" and Delivery Reports for "how has this performed". At Enterprise's 3-second interval, second precision in the log is not a nicety — minute-rounded timestamps would collapse about twenty distinct pushes into one apparent moment.
coverage_basis comes back as none and coverage is blank. That is correct, not missing: one asset measured against your whole subscription's configured count would produce a meaningless percentage — a subscription with 30 assets filtered to BTC would read about 3% coverage and look catastrophic while nothing whatsoever is wrong. We would rather show you nothing than something confidently wrong.
Three endpoints. Authentication is your webhook API key, presented as an X-API-Key header, an Authorization: Bearer header, or an api_key query parameter. No other entitlement is required — owning the subscription is the whole gate.
| Method | Endpoint | Returns |
|---|---|---|
GET | /webhook/reports/daily | One row per day per transport |
GET | /webhook/reports/summary | Range totals plus a per-transport breakdown |
POST | /webhook/reports/refresh | Starts an on-demand rollup recompute — returns 202 immediately, does not wait for the recompute to finish |
GET | /webhook/reports/refresh/status | Poll this to learn when the refresh you just started has finished |
| Parameter | Format | Default |
|---|---|---|
start_date | YYYY-MM-DD | 30 days ago |
end_date | YYYY-MM-DD | Today |
method | https or udp | Both |
asset | Asset symbol, case-insensitive | All assets |
format | csv, tsv, text | JSON |
# Last 30 days, day by day
curl -H "X-API-Key: $WEBHOOK_KEY" \
"https://api.cpmp-site.org/webhook/reports/daily"
# A specific month, HTTPS only, as CSV
curl -H "X-API-Key: $WEBHOOK_KEY" \
"https://api.cpmp-site.org/webhook/reports/daily?start_date=2026-07-01&end_date=2026-07-31&method=https&format=csv" \
-o july-https.csv
# Range summary as a human-readable block
curl -H "X-API-Key: $WEBHOOK_KEY" \
"https://api.cpmp-site.org/webhook/reports/summary?format=text"
# One asset, to investigate a gap
curl -H "X-API-Key: $WEBHOOK_KEY" \
"https://api.cpmp-site.org/webhook/reports/daily?asset=SOL"
# Start a refresh of today's numbers — returns immediately
curl -X POST -H "X-API-Key: $WEBHOOK_KEY" \
"https://api.cpmp-site.org/webhook/reports/refresh"
# {"status":"started","message":"Delivery report refresh started"}
# Poll for the result (repeat every second or two until status is "done")
curl -H "X-API-Key: $WEBHOOK_KEY" \
"https://api.cpmp-site.org/webhook/reports/refresh/status"
# {"status":"done","rows_updated":3,"refreshed_at":"2026-08-21T18:04:11Z"}
Both GET endpoints return the standard 12-field Unified Messaging Envelope with your payload in data, the same shape as every other endpoint in this infrastructure. Check status_code, throw on error, read data — and never write a fallback for a missing field, because there are none. Every field is always present. See the envelope reference for the full contract.
refresh: it is asynchronous — the POST starts the recompute and returns 202 immediately with {"status":"started"}, it does not wait for the rollup to finish. Poll GET /webhook/reports/refresh/status (returns running, done with rows_updated, error, or not_found if nothing has been started) to learn when it's ready, then re-fetch /webhook/reports/daily or /summary. Starting a new refresh is limited to once per 60 seconds per subscription and recomputes a trailing few days. You rarely need it — the rollup refreshes itself every 15 minutes (section 11). It exists for the case where you are actively investigating something and do not want to wait for the next cycle. A POST that returns 429 or a status poll that reads already_running both mean the same thing: a refresh is already in flight or just finished, and your report is already current to within a few minutes.
| Field | Meaning |
|---|---|
day | Eastern calendar date |
delivery_method | https or udp |
push_events | Pushes that day on this transport |
successful_events / failed_events | Delivered and failed push counts |
delivery_rate_pct | Successful over total, as a percentage |
asset_rows | Total asset rows — raw delivered volume |
distinct_assets | Largest single push that day, in distinct assets |
configured_selected_assets | Your configured count as it stands now |
typical_assets_per_push | Mode of assets per push — the day's usual shape |
avg_assets_per_push | Average assets delivered per push. Coverage numerator. |
avg_configured_per_push | Recorded configured count, push-weighted. 0 when unavailable. |
coverage_denominator | The exact value coverage was divided by. Never re-derive this yourself. |
coverage_basis | Provenance of that denominator — see 6.3 |
asset_coverage_pct | Coverage percentage |
min_coverage_pct | Worst single push that day |
pushes_at_full_coverage | Pushes that delivered every configured asset |
avg_latency_ms / p95_latency_ms | Average and 95th percentile latency |
| Field | Meaning |
|---|---|
total_push_events | All pushes across the range and both transports |
total_successful / total_failed | Range delivery totals |
delivery_rate_pct | Range-wide delivery rate |
total_asset_rows | Total asset rows delivered |
avg_assets_per_push | Push-weighted average across the range |
configured_max_assets | Largest denominator resolved in the range |
asset_coverage_pct | Push-weighted average of each day's own coverage |
min_coverage_pct | Worst push across measured days |
pushes_at_full_coverage | Complete pushes across measured days |
pushes_coverage_measured | The denominator for the two fields above — how many pushes they cover |
avg_latency_ms | Push-weighted average latency |
by_delivery_method | Per-transport breakdown. Check this first when a rate looks off. |
Everything you read about your webhook delivery is available in 12 languages, driven by a single setting in your Account Dashboard. Not machine-translated on the fly in a browser widget — deliberately translated, stored, and served.
| Surface | In your language? | Detail |
|---|---|---|
| Account Dashboard, including the Delivery Reports and Delivery Log panels | Yes | Every label, heading, column and control. Right-to-left layout for Arabic and Urdu. |
| Webhook emails — subscription confirmation, plan changes, asset auto-adjustment | Yes | Composed in your language, with per-string fallback so a partial gap can never produce an unreadable email. |
| This documentation, and the whole Document Library | Yes | Translated by our own engine, which protects code and diagrams from translation. |
| The price payload we push to your endpoint | Deliberately not | Fixed English field names, forever. This is a feature — see below. |
The 12 languages are English, Spanish, Portuguese, French, German, Russian, Hindi, Urdu, Italian, Arabic, Japanese, and Chinese (Simplified).
That last table row is the important one, and it is a principle rather than a gap.
We translate the understanding. We never translate the execution.
Your delivery payload has fixed English field names — asset, price, source, updated_at — and they will never change based on a language preference. Think about what the alternative would mean: a customer switching their dashboard to Japanese and their parser breaking in production because price silently became 価格. A language setting is a preference about reading. It must never be a breaking change to an integration.
This is the same discipline we apply to our documentation, which is why you can read a translated page in Urdu and copy-paste a code block from it that works on the first try — code blocks, commands, identifiers and diagrams are all protected from translation. Prose is for humans; identifiers are for machines. Confusing the two is how translated technical documentation becomes useless.
language field. Every push includes it, set from your API language preference. It is an honest declaration of your preference rather than a description of the payload's contents — because a successful price push contains no human-readable prose to translate. It is asset symbols, numbers, exchange names and timestamps. The field exists because every response this infrastructure produces carries the same 12 envelope fields without exception, and consistency is worth more than omitting a field on the one payload where it has little to say.
flowchart TB
SET["Account Dashboard
Profile settings"]
SET --> PL["Correspondence language
preferred_lang"]
SET --> AL["API language
api_lang"]
PL --> DASH["Dashboard UI
Delivery Reports panel
12 languages · RTL aware"]
PL --> MAIL["Webhook emails
confirmation · plan change
asset auto-adjustment"]
PL --> DOCS["Document Library
this page, 12 languages"]
AL --> ENV["Payload language field
declares your preference"]
PAY["Price payload
asset · price · source · updated_at"]
PAY --> FIXED["Field names fixed in English
never change with language"]
ENV -.->|"travels with"| PAY
style DASH fill:#064e3b,stroke:#10b981,color:#d1fae5
style MAIL fill:#064e3b,stroke:#10b981,color:#d1fae5
style DOCS fill:#064e3b,stroke:#10b981,color:#d1fae5
style FIXED fill:#1e3a5f,stroke:#60a5fa,color:#dbeafe
style PAY fill:#334155,stroke:#64748b,color:#e2e8f0
Your profile offers two language choices, and the distinction is worth thirty seconds of your time because they do genuinely different jobs.
| Setting | Governs |
|---|---|
| Preferred language | Everything written to you — your dashboard, your emails, your invoices and checkout pages. If you only set one, set this one. |
| API response language | The language declared on API responses, and the language of API message text where it applies. |
| Emoji status indicators | Not a language, but it lives beside them: our status lines use indicators like ✅ by default. Switch to plain text if your log pipeline prefers it. |
Both are saved from the same panel in one action, and both take effect immediately — your dashboard re-renders in the new language on the next load, and your emails follow from the next one sent.
Your delivery log is written the instant a push happens. Your delivery reports read the rollup, which is refreshed on a schedule. So the practical question is: how stale can a report be?
Answer: about 15 minutes, worst case.
| Path | Cadence | Window covered |
|---|---|---|
| Scheduled refresh inside Aurora | Every 15 minutes, continuously | Trailing 2 days |
| BeastReconciler nightly run | Once nightly, 1 AM Eastern | Trailing 3 days, every subscription |
Refresh Now / POST /webhook/reports/refresh | On demand, 60-second cooldown | Today |
Three overlapping paths, on purpose. The 15-minute cycle keeps your numbers current without you doing anything. The nightly pass is wider and covers every subscription, so a day is recomputed with full context after it has completely finished — and because its window is deliberately wider than one day, a single missed run heals itself on the next one rather than leaving a permanent hole. Refresh Now exists for when you are actively investigating and 15 minutes feels long.
Every refresh is an idempotent recomputation from the raw log, not an incremental adjustment. Running one twice changes nothing, and a day recomputed a week later produces the same answer it did on the day. That property is what lets us have three overlapping schedules at all without them corrupting each other.
last_refreshed. Each rollup row carries the timestamp of its last recomputation. If a number surprises you, check it — a figure from four minutes ago is describing a day still in progress, and today's row will naturally read differently at 9 AM than it will at midnight.
Nothing. There is no reporting tier, no per-query fee, no storage charge, no export limit, and no add-on to buy.
We want to be direct about why, because the reasoning is not charity and it is not a loss leader.
You are paying for delivery. Proof of delivery is part of delivery. A price feed you cannot audit is a price feed you have to take on faith, and asking you to pay extra for the evidence that we did the job you already paid for would be, frankly, a strange thing to sell. Metering it would also change our incentives in a direction we do not want: the moment reporting becomes a revenue line, there is quiet pressure to make the free view less useful. We would rather every Associate be able to check our work, all the time, at no cost, and hold us to it.
It also happens to be inexpensive to provide, and the engineering is the reason. The rollup means your report reads about 60 rows rather than scanning millions. Recording the configured count costs nothing because the value was already in memory. Storage is a table that prunes itself nightly. Good architecture is what makes generosity affordable — if this had been built as a naive query against the raw log, it would have been slow, expensive, and inevitably rationed.
We have earned the right to say a few things plainly.
And if a number in your reports ever looks wrong — especially if it looks impossible — please open a support ticket and tell us. Our assistant Rhema will pick it up immediately and route it. Two of the three defects described in this document were found by someone looking at a figure and saying "that cannot be right." That instinct is genuinely valuable to us, and we would rather hear it than not.