Rustle docs
Rustle is an app-review radar. It watches the Apple App Store and Google Play for new reviews and rating movements on the apps you care about, and delivers each change to your webhook as a structured event, reliably, exactly once, and politely.
It is a plumbing product. The entire value is correctness:
- It never double-fires. A given review or rating drop produces at most one delivered event, ever, even under re-polling and at-least-once redelivery.
- It never silently dies. Parser breakage and scraper blocks fail loudly; they are never swallowed.
- It is a good citizen. Conservative, jittered, backed-off request rates against both stores.
The mental model
App Store ─┐
├─► poll ─► normalize ─► deliver exactly-once ─► your webhook
Google Play ─┘ (one schema) (signed, deduped)
You never run a scraper, never poll a store, and never branch on which store a review came from. You register a callback URL, and Rustle POSTs you a small JSON event when something happens.
Two events
| Event | Fires when |
|---|---|
review.created | A new review lands on either store (filter by star rating, locale, keyword). |
rating.dropped | An app’s aggregate rating crosses a threshold, or falls by a delta you set. |
Both share one store-agnostic envelope and carry a schema_version
(currently 1).
How you’ll use it
- Directly, via the REST Hook API: register and remove webhook callbacks with an API token.
- Through Zapier (early access): trigger Zaps on new reviews and rating drops, no endpoint to host.
Not real-time, on purpose. Reviews fire as soon as the stores publish them, typically within hours for Apple and about a day for Google. Rustle is not real-time because the stores aren’t; it would rather be exactly-once than pretend to be instant.
Start with the Quickstart.
Quickstart
From zero to your first event in three steps.
1. Get an API token
Sign in to the console at app.rustle.cloud, open Integrations,
and issue a token. It looks like rsk_… and is shown once; store it somewhere safe.
You authenticate every API call with it, as either header:
Authorization: Bearer rsk_your_token
X-API-Key: rsk_your_token
2. Register a webhook
Point Rustle at a URL you control. This example fires on new 1–2★ reviews of an App Store app in the US storefront:
curl -X POST https://app.rustle.cloud/api/v1/hooks \
-H "Authorization: Bearer rsk_your_token" \
-H "Content-Type: application/json" \
-d '{
"target_url": "https://example.com/webhooks/rustle",
"store": "apple",
"app_id": "284882215",
"country": "us",
"event_type": "review.created",
"min_stars": 1,
"max_stars": 2
}'
The response returns the hook id and a signing secret (shown once; keep it to
verify signatures):
{
"id": "api-1a2b3c4d5e6f7a8b",
"secret": "<webhook signing secret — shown once>",
"store": "apple",
"app_id": "284882215",
"country": "us",
"event_type": "review.created"
}
That’s it. The app is now in the poll set. See Filters & storefronts
for the full set of options, and rating.dropped for rating
alerts.
3. Receive an event
When a matching review appears, Rustle POSTs a JSON body to your target_url, with two
headers:
POST /webhooks/rustle HTTP/1.1
x-radar-event-id: 6f1c… # dedupe on this
x-radar-signature: sha256=9a0b… # verify this
Content-Type: application/json
{
"event_id": "6f1c…",
"occurrence_id": "…",
"event_type": "review.created",
"store": "apple",
"app_id": "284882215",
"subscriber_id": "api-1a2b3c4d5e6f7a8b",
"occurred_at": "2026-06-02T14:08:11Z",
"observed_at": "2026-06-02T14:09:03Z",
"schema_version": 1,
"review_id": "10982334771",
"fingerprint": "…",
"content_hash": "…",
"rating": 2,
"title": null,
"body": "Crashes on launch since 4.2.",
"author": "tess_w",
"app_version": "4.2.0",
"country": "us"
}
Two rules make this safe to consume:
- Dedupe on
event_id: delivery is at-least-once, so a rare redelivery is possible by design. - Verify
x-radar-signature: confirm the body really came from Rustle.
To stop receiving events, remove the hook:
curl -X DELETE https://app.rustle.cloud/api/v1/hooks/api-1a2b3c4d5e6f7a8b \
-H "Authorization: Bearer rsk_your_token"
Events & delivery
Every event Rustle delivers shares one store-agnostic envelope. The store of origin is a
field (store), never something your code has to branch on. A review from Apple and a
review from Google arrive in exactly the same shape.
The envelope
These fields are present on every event, regardless of type:
| Field | Type | Notes |
|---|---|---|
event_id | string | Identity of this delivery to this subscriber. Deterministic (not random). Dedupe on it. Also sent as the x-radar-event-id header. |
occurrence_id | string | Identity of the underlying occurrence, shared across every subscriber it fans out to. Lets you recognise “the same review, delivered to me.” |
event_type | enum | review.created or rating.dropped. |
store | enum | apple or google. A field, not a code branch. |
app_id | string | Store-native app id (Apple numeric id, Google package name). |
subscriber_id | string | The hook this was delivered to (your hook id). |
occurred_at | RFC 3339 | When the change happened at the source. |
observed_at | RFC 3339 | When Rustle detected it (poll time). |
schema_version | integer | Starts at 1; bumped only on a breaking payload change. |
The type-specific fields (review_id, rating, body, … for reviews;
current_rating, delta, … for rating drops) sit at the top level alongside the
envelope. The payload is flattened, not nested. See each event’s reference:
review.created, rating.dropped.
Delivery
Rustle POSTs the event as a JSON body to your hook’s target_url, with two headers:
| Header | Purpose |
|---|---|
x-radar-event-id | The event’s event_id; dedupe on it. |
x-radar-signature | sha256=<hex> HMAC of the exact body; verify it. |
A 2xx response means you accepted the event. Any other response (or a timeout) is treated
as a failure and retried with backoff; events that exhaust their retries land in a
dead-letter queue rather than being dropped.
Forward-looking by default
A new hook only receives events that occur at or after it was created. It does not replay the back-catalogue already sitting in a store’s feed. (The same is true for rating drops: the first observation seeds the baseline silently.)
Exactly-once & idempotency
Rustle’s core promise is that a given review or rating drop produces at most one delivered event, no double-fires, and that nothing is silently dropped. Here’s the contract you consume against.
Delivery is at-least-once; you dedupe on event_id
Committing a database row and making an external HTTP POST can never be made atomic (the two-generals problem). So exactly-once delivery to an arbitrary endpoint is impossible, and Rustle makes the honest choice: at-least-once delivery, deduped by the consumer.
In practice you will almost always receive each event once. But a delivery whose confirmation
was lost (we POSTed, your 2xx came back, but our confirm write didn’t land) is re-attempted
rather than risk dropping it. That’s the rare redelivery the contract warns about.
The rule: treat
event_idas an idempotency key. If you’ve already processed anevent_id, ignore the redelivery. It’s also sent as thex-radar-event-idheader so you can dedupe before even parsing the body.
event_id is deterministic, the same occurrence delivered to the same hook always
produces the same event_id, which is exactly what makes consumer-side dedupe reliable.
# Pseudocode for an idempotent receiver
event_id = request.headers["x-radar-event-id"]
if seen.contains(event_id):
return 200 # already handled — ack and move on
process(request.json)
seen.add(event_id)
return 200
What you don’t have to worry about
- Re-polling. Rustle re-reads the stores constantly; an already-seen review is never re-emitted. The dedupe happens server-side, before fan-out.
- An author editing their review. Review identity excludes the review’s content, so an
edit does not re-fire
review.created. (A separatecontent_hashis provided for your own edit detection, but it never affects identity.) - Ordering. Events are not guaranteed to arrive in occurrence order. Each event is
self-describing (
occurred_at,observed_at); don’t rely on delivery order.
Occurrence vs. delivery
Two ids, two jobs:
event_id: this delivery to this hook. Your idempotency key.occurrence_id: the underlying review or rating drop, shared if you have several hooks watching the same app. Use it to recognise “the same thing, delivered to more than one of my hooks.”
review.created
Fires once per newly-seen review that passes your star filter. Carries the common envelope plus the fields below.
Payload fields
| Field | Type | Notes |
|---|---|---|
review_id | string | Store-native, stable review id. |
fingerprint | string | hash(store + app_id + review_id): identity only. This is the event’s occurrence_id. |
content_hash | string | Hash of title + body. Non-key, for your own edit detection; it never affects identity. |
rating | integer | 1–5. |
title | string | null | May be absent. |
body | string | The review text. |
author | string | null | Store-provided display name. |
app_version | string | null | Version reviewed, when the source provides it. |
country | string | Storefront/locale the review was polled from (lowercase ISO-3166 alpha-2). |
Filtering
A hook configures min_stars / max_stars (each 1–5, default 1–5). The filter gates
delivery, never dedupe. A filtered-out review is still recorded as seen, so widening the
filter later never replays it. See Filters & storefronts.
Example
{
"event_id": "sample-review-event-id",
"occurrence_id": "sample-fingerprint",
"event_type": "review.created",
"store": "apple",
"app_id": "284882215",
"subscriber_id": "zapier-sample",
"occurred_at": "2026-06-01T12:00:00Z",
"observed_at": "2026-06-01T12:05:00Z",
"schema_version": 1,
"review_id": "rev-1",
"fingerprint": "sample-fingerprint",
"content_hash": "sample-content-hash",
"rating": 5,
"title": "Great app",
"body": "I love it",
"author": "jane",
"app_version": "3.2.1",
"country": "us"
}
Editing a review’s body does not re-fire
review.created; identity excludes content. Usecontent_hashif you want to detect edits yourself.
You can fetch a synthetic example of this shape any time from
GET /api/v1/sample?event_type=review.created.
rating.dropped
Fires when an app’s aggregate rating crosses below a threshold you set, or falls by a delta versus the last observed aggregate. Carries the common envelope plus the fields below.
Payload fields
| Field | Type | Notes |
|---|---|---|
transition_id | string | hash(store + app_id + current_rating + rating_count): identity. This is the event’s occurrence_id. Deterministic, so re-observing the same aggregate dedupes. |
previous_rating | float | Last observed aggregate. |
current_rating | float | Newly observed aggregate. |
delta | float | current − previous (negative on a drop). |
rating_count | integer | Total ratings backing the current aggregate. |
trigger | enum | threshold or delta (which rule fired). |
threshold | float | null | The configured threshold, when trigger is threshold. |
Rules
A hook can set a threshold (1–5) and/or a delta (0–4); at least one is required.
- threshold: fire when
current_ratingcrosses below the threshold. - delta: fire when the rating falls by at least that much versus the last observation.
The first time Rustle observes an app’s rating it seeds the baseline silently (no event), so you only hear about movement from there on. Re-observing an unchanged aggregate never re-fires. See Filters & storefronts.
Example
{
"event_id": "sample-rating-event-id",
"occurrence_id": "sample-transition",
"event_type": "rating.dropped",
"store": "google",
"app_id": "com.example.app",
"subscriber_id": "zapier-sample",
"occurred_at": "2026-06-01T12:00:00Z",
"observed_at": "2026-06-01T12:05:00Z",
"schema_version": 1,
"transition_id": "sample-transition",
"previous_rating": 4.2,
"current_rating": 3.9,
"delta": -0.3,
"rating_count": 12345,
"trigger": "threshold",
"threshold": 4.0
}
You can fetch a synthetic example any time from
GET /api/v1/sample?event_type=rating.dropped.
Receiving & verifying events
Rustle delivers each event as an HTTPS POST to your hook’s target_url. This page covers
how to receive it safely.
The request
POST /your/webhook HTTP/1.1
Content-Type: application/json
x-radar-event-id: 6f1c…
x-radar-signature: sha256=9a0b…
Respond 2xx to acknowledge. Anything else (or a timeout) is treated as a failure and
retried with exponential backoff; exhausted retries go to a dead-letter queue, never a
silent drop.
Two things to do on every request
- Verify
x-radar-signature: prove the body really came from Rustle (below). - Dedupe on
x-radar-event-id: delivery is at-least-once.
Verifying the signature
The signature is HMAC-SHA256 over the exact raw body bytes, using your hook’s signing secret (returned once when you created the hook). The header value format is:
x-radar-signature: sha256=<lowercase hex digest>
Verify against the raw bytes, before any JSON parse-and-re-serialize. Re-stringifying the parsed JSON can change whitespace or key order and break the signature. Most frameworks expose the raw body (e.g. a
rawBodybuffer); use that.
Node.js
const crypto = require("crypto");
function verify(secret, rawBody, header) {
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(header || "");
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b); // constant-time
}
Python
import hmac, hashlib
def verify(secret: str, raw_body: bytes, header: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header or "") # constant-time
Rust
Rustle signs with the same routine it ships for verification, radar_dispatch::sign::verify:
use hmac::{Hmac, Mac};
use sha2::Sha256;
pub fn verify(secret: &str, body: &[u8], header_value: &str) -> bool {
let Some(hex_tag) = header_value.strip_prefix("sha256=") else { return false };
let Some(tag) = hex::decode(hex_tag).ok() else { return false };
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
mac.verify_slice(&tag).is_ok() // constant-time
}
If verification fails, reject the request (401) and do not process the body.
A minimal receiver
@app.post("/webhooks/rustle")
def receive():
raw = request.get_data() # raw bytes — do not use request.json here
if not verify(SECRET, raw, request.headers.get("x-radar-signature", "")):
return "", 401
event_id = request.headers["x-radar-event-id"]
if already_processed(event_id): # idempotency
return "", 200
handle(request.get_json())
mark_processed(event_id)
return "", 200
Authentication
The REST Hook API is authenticated with an API token. Issue one in the console at app.rustle.cloud → Integrations.
- Tokens look like
rsk_…and are shown once, when created. Store the value securely. - A token is scoped to the account that created it. Every call is automatically limited to that account’s hooks. One account’s token can never see or touch another’s.
- Tokens are stored only as a SHA-256 hash server-side; Rustle can verify a token but never reproduce it. Lost it? Revoke it and issue a new one.
- Revoke a token any time from the same Integrations page.
Presenting the token
Send it as either header (both are accepted):
Authorization: Bearer rsk_your_token
X-API-Key: rsk_your_token
A missing or invalid token returns 401.
curl https://app.rustle.cloud/api/v1/hooks \
-H "Authorization: Bearer rsk_your_token"
Base URL:
https://app.rustle.cloud. All endpoints below are under/api/v1.
The signing secret returned when you create a hook is a different credential: it is not an API token, and is used only to verify webhook signatures.
Endpoints
The REST Hook API lets a program register and remove webhook callbacks. Base URL
https://app.rustle.cloud, all under /api/v1, all bearer-authenticated.
For the exhaustive, machine-readable schema, see the API reference (generated from the code).
POST /api/v1/hooks
Create a hook: a callback URL + the matching watch. The app enters the poll set immediately.
Body
| Field | Required | Default | Notes |
|---|---|---|---|
target_url | yes | — | The URL events are delivered to (http/https). |
store | yes | — | apple or google. |
app_id | yes | — | Store-native app id. |
event_type | yes | — | review.created or rating.dropped. |
country | no | us | Storefront, see storefronts. |
min_stars / max_stars | no | 1 / 5 | review.created star band (each 1–5). |
threshold | no | — | rating.dropped threshold (1–5). |
delta | no | — | rating.dropped delta (0–4). At least one of threshold/delta is required. |
source | no | api | Provenance hint: zapier / make / n8n / api. |
external_ref | no | — | Your own correlation crumb (≤ 256 chars). |
curl -X POST https://app.rustle.cloud/api/v1/hooks \
-H "Authorization: Bearer rsk_your_token" \
-H "Content-Type: application/json" \
-d '{"target_url":"https://example.com/hook","store":"google",
"app_id":"com.acme.notes","event_type":"rating.dropped","threshold":4.5}'
Response 200, returns the hook id and the signing secret (shown once):
{ "id": "api-1a2b3c4d5e6f7a8b", "secret": "…", "store": "google",
"app_id": "com.acme.notes", "country": "us", "event_type": "rating.dropped" }
Invalid input (bad store, URL, event type, star band, or rating rule) returns 400 with
{ "error": "…" }.
GET /api/v1/hooks
List every hook this account owns.
curl https://app.rustle.cloud/api/v1/hooks -H "Authorization: Bearer rsk_your_token"
[ { "id": "api-1a2b…", "source": "api",
"target_url": "https://example.com/hook", "external_ref": null } ]
DELETE /api/v1/hooks/{id}
Remove a hook and its watch (and drop the app from the poll set if nothing else watches it).
curl -X DELETE https://app.rustle.cloud/api/v1/hooks/api-1a2b3c4d5e6f7a8b \
-H "Authorization: Bearer rsk_your_token"
Returns 204 No Content on success, or 404 if no hook with that id belongs to you.
GET /api/v1/apps
The apps this account already watches, handy for populating a dropdown. (Subscribe also accepts free-text app ids.)
curl https://app.rustle.cloud/api/v1/apps -H "Authorization: Bearer rsk_your_token"
GET /api/v1/sample
A synthetic, representative event, for a platform’s “test trigger” step. It never touches your data, so any valid token works. Returns a one-element array.
curl "https://app.rustle.cloud/api/v1/sample?event_type=review.created" \
-H "Authorization: Bearer rsk_your_token"
event_type is review.created (default) or rating.dropped. See the
event reference for the exact shape.
API reference
The full, machine-readable reference for the REST Hook API is generated from the Rust
handlers (via utoipa) and committed as
openapi.json, so it can never drift from the running code.
You can also import openapi.json straight into Postman, Insomnia, or any
OpenAPI client, or generate a client from it.
Prefer prose? The Endpoints page walks through each call with curl
examples.
Filters & storefronts
Filters decide which events a hook receives. They gate delivery, never dedupe. A review that a filter excludes is still recorded as seen, so widening a filter later never replays the back-catalogue.
review.created: star band
Set min_stars and max_stars (each 1–5, with min ≤ max). Defaults to 1–5 (every
review).
{ "event_type": "review.created", "min_stars": 1, "max_stars": 2 } // only 1–2★ reviews
rating.dropped: threshold & delta
Set a threshold (1–5) and/or a delta (0–4); at least one is required.
threshold: fire when the aggregate rating crosses below this value.delta: fire when the rating falls by at least this much versus the last observation.
{ "event_type": "rating.dropped", "threshold": 4.5 } // dips below 4.5
{ "event_type": "rating.dropped", "delta": 0.2 } // drops by 0.2+
{ "event_type": "rating.dropped", "threshold": 4.0, "delta": 0.3 } // either rule
Storefronts
A watch targets one storefront via country (lowercase ISO-3166 alpha-2; defaults to us).
The catalogue is curated. Here are the supported codes:
| Code | Storefront | Code | Storefront |
|---|---|---|---|
us | United States | se | Sweden |
gb | United Kingdom | br | Brazil |
ca | Canada | mx | Mexico |
au | Australia | jp | Japan |
ie | Ireland | kr | South Korea |
fr | France | in | India |
de | Germany | nl | Netherlands |
es | Spain | it | Italy |
The same list serves both stores. A country outside this set returns 400. To watch one
app across several storefronts, register one hook per storefront.
The baseline
Every hook is forward-looking: it only receives events that occur at or after it was
created. There is no lookback. A brand-new hook does not replay reviews already sitting in a
store’s feed, and rating.dropped seeds its baseline silently on first observation.
Zapier
Early access. The Rustle Zapier app isn’t on the public Zapier marketplace yet. Request access from rustle.cloud/integrations/zapier. The triggers and behaviour below are what ship.
Zapier lets you turn Rustle events into actions in 7,000+ apps (post to Slack, add a row to Google Sheets, open a Linear issue) with no endpoint to host. Under the hood the Zapier app just calls the REST Hook API, so anything you can do here you can also do directly.
Triggers
| Trigger | Event | Fires on |
|---|---|---|
| New Review | review.created | A new App Store / Google Play review (filter by store, app, storefront, star band). |
| Rating Dropped | rating.dropped | The app’s aggregate rating crosses a threshold or falls by a delta. |
Both deliver the same normalized payload; your Zap never branches on whether the review came from Apple or Google.
Setting up a Zap
- Add Rustle as the trigger app and pick New Review or Rating Dropped.
- Connect your account: paste an API token (
rsk_…) from the console (app.rustle.cloud → Integrations). The base URL ishttps://app.rustle.cloud. - Configure the trigger: choose the store, app, storefront, and filters (star band for reviews; threshold/delta for ratings). These map directly to the filter options.
- Test: Zapier pulls a sample event so you can map fields before going live.
- Add an action (Slack, Sheets, Discord, Linear, whatever you like) and map the event
fields (
rating,body,author,app_version, …) into it.
Zapier dedupes triggers on the event’s id, which Rustle aligns with event_id, so a rare
redelivery won’t double-run your Zap.
Recipe ideas
- New 1★ review → message in Slack
#support - Rating falls below 4.5 → email the founder / page via PagerDuty
- Review mentions “crash” or “refund” → new Linear issue
- Any new review → row in Google Sheets
Doing it without Zapier
The Zapier app is a thin wrapper over the API. “Turn on a trigger” is
POST /api/v1/hooks; “turn it off” is
DELETE /api/v1/hooks/{id}; the “test” step is
GET /api/v1/sample. Make and n8n use the same API;
guides coming soon.
Changelog
Event schema
The delivered event payload carries a schema_version. It is bumped only on a breaking
change to the payload; additive, backward-compatible fields do not bump it.
schema_version | Status | Notes |
|---|---|---|
1 | Current | review.created and rating.dropped, the store-agnostic envelope, HMAC-SHA256 signatures. |
Write your consumer defensively: ignore unknown fields, and key your idempotency on
event_id.
API
The REST Hook API lives under /api/v1. The authoritative, versioned contract is the
OpenAPI reference, generated from the code.
Coming
- Make and n8n integration guides (the API already supports them via
source). - Additional events and a public roadmap.