Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

EventFires when
review.createdA new review lands on either store (filter by star rating, locale, keyword).
rating.droppedAn 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:

  1. Dedupe on event_id: delivery is at-least-once, so a rare redelivery is possible by design.
  2. 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:

FieldTypeNotes
event_idstringIdentity of this delivery to this subscriber. Deterministic (not random). Dedupe on it. Also sent as the x-radar-event-id header.
occurrence_idstringIdentity of the underlying occurrence, shared across every subscriber it fans out to. Lets you recognise “the same review, delivered to me.”
event_typeenumreview.created or rating.dropped.
storeenumapple or google. A field, not a code branch.
app_idstringStore-native app id (Apple numeric id, Google package name).
subscriber_idstringThe hook this was delivered to (your hook id).
occurred_atRFC 3339When the change happened at the source.
observed_atRFC 3339When Rustle detected it (poll time).
schema_versionintegerStarts 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:

HeaderPurpose
x-radar-event-idThe event’s event_id; dedupe on it.
x-radar-signaturesha256=<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_id as an idempotency key. If you’ve already processed an event_id, ignore the redelivery. It’s also sent as the x-radar-event-id header 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 separate content_hash is 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

FieldTypeNotes
review_idstringStore-native, stable review id.
fingerprintstringhash(store + app_id + review_id): identity only. This is the event’s occurrence_id.
content_hashstringHash of title + body. Non-key, for your own edit detection; it never affects identity.
ratinginteger1–5.
titlestring | nullMay be absent.
bodystringThe review text.
authorstring | nullStore-provided display name.
app_versionstring | nullVersion reviewed, when the source provides it.
countrystringStorefront/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. Use content_hash if 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

FieldTypeNotes
transition_idstringhash(store + app_id + current_rating + rating_count): identity. This is the event’s occurrence_id. Deterministic, so re-observing the same aggregate dedupes.
previous_ratingfloatLast observed aggregate.
current_ratingfloatNewly observed aggregate.
deltafloatcurrent − previous (negative on a drop).
rating_countintegerTotal ratings backing the current aggregate.
triggerenumthreshold or delta (which rule fired).
thresholdfloat | nullThe 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_rating crosses 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

  1. Verify x-radar-signature: prove the body really came from Rustle (below).
  2. 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 rawBody buffer); 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.cloudIntegrations.

  • 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

FieldRequiredDefaultNotes
target_urlyesThe URL events are delivered to (http/https).
storeyesapple or google.
app_idyesStore-native app id.
event_typeyesreview.created or rating.dropped.
countrynousStorefront, see storefronts.
min_stars / max_starsno1 / 5review.created star band (each 1–5).
thresholdnorating.dropped threshold (1–5).
deltanorating.dropped delta (0–4). At least one of threshold/delta is required.
sourcenoapiProvenance hint: zapier / make / n8n / api.
external_refnoYour 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.

Open the API reference →

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 15, with min ≤ max). Defaults to 15 (every review).

{ "event_type": "review.created", "min_stars": 1, "max_stars": 2 }  // only 1–2★ reviews

rating.dropped: threshold & delta

Set a threshold (15) and/or a delta (04); 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:

CodeStorefrontCodeStorefront
usUnited StatesseSweden
gbUnited KingdombrBrazil
caCanadamxMexico
auAustraliajpJapan
ieIrelandkrSouth Korea
frFranceinIndia
deGermanynlNetherlands
esSpainitItaly

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

TriggerEventFires on
New Reviewreview.createdA new App Store / Google Play review (filter by store, app, storefront, star band).
Rating Droppedrating.droppedThe 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

  1. Add Rustle as the trigger app and pick New Review or Rating Dropped.
  2. Connect your account: paste an API token (rsk_…) from the console (app.rustle.cloud → Integrations). The base URL is https://app.rustle.cloud.
  3. 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.
  4. Test: Zapier pulls a sample event so you can map fields before going live.
  5. 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_versionStatusNotes
1Currentreview.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.