# SASO

SASO tracks the Shopify App Store: app listings, keyword rankings, category
rankings and reviews. When a listing field changes, the old value is kept.
Every field has a full history with `valid_from` and `valid_to` timestamps.

Currently tracking 28,082 apps, 37,679 keywords and 883,676 reviews.
Generated 2026-09-01T06:32:28.581Z.

The dashboard is at https://heysaso.com. This page documents the SQL API.

## Quick start

### 1. Request a key

Subscribe at https://buy.polar.sh/polar_cl_G51ytqlm9epJoWwlnpngxJFbqodHIt7FZUA2Y3kKKln
for $99 per month. Polar is the merchant of record. After you subscribe, your
API key appears immediately on the checkout success page. It is shown once.
If you lose it, issue a new one at https://heysaso.com/account/key. The
previous key stops working immediately when its replacement is issued.
Keys look like `saso_live_…` and go in an `Authorization: Bearer` header.

### 2. Run a query

```sh
curl -X POST https://heysaso.com/api/v1/query \
  -H "Authorization: Bearer $SASO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"sql": "SELECT app_handle, name, launched_at FROM api.current_app ORDER BY launched_at DESC NULLS LAST LIMIT 5"}'
```

The response is JSON:

```json
{ "rows": [ … ], "rowCount": 5, "truncated": false, "executionMs": 12 }
```

`truncated` is true when the query returned more than the row cap and the
extra rows were dropped. Errors come back as `{ "error": "…" }` with a 4xx
status.

### 3. Read the schema

```sh
curl https://heysaso.com/api/v1/schema -H "Authorization: Bearer $SASO_API_KEY"
```

Machine-readable column lists and prose notes for every view. Read this
before writing anything non-trivial.

### 4. Check your usage

```sh
curl https://heysaso.com/api/v1/usage -H "Authorization: Bearer $SASO_API_KEY"
```

## Cancelling

Cancel at https://polar.sh/floships/portal. Sign in with the email address
you paid with, then enter the one-time verification code Polar sends by
email. Polar also links this portal from its order confirmation and renewal
emails.

Your API key keeps working until the end of the paid period.

## Coverage

Listing and ranking tracking started on August 23, 2026 and runs
continuously.

Reviews carry Shopify's own review dates. They go back to September 2012
and are complete to today.

The current state of every listing is always fresh. History grows every day.

## Views

Five flattened views cover most questions. They live in the `api` schema.

### api.current_app

One row per app with its current listing, developer, support, classification, and media values.

Columns: app_id bigint, app_handle varchar, name varchar | null, icon_url text | null, introduction text | null, details text | null, features text | null, card_subtitle varchar | null, title_tag varchar | null, meta_description_tag text | null, developer_id bigint | null, developer_handle varchar | null, developer_name varchar | null, developer_address text | null, support_email varchar | null, support_portal_url text | null, demo_store_url text | null, built_for_shopify boolean, launched_at timestamp | null, categories jsonb, category_details jsonb, languages jsonb, integrations jsonb, screenshots jsonb, feature_media jsonb, created_at timestamp

### api.current_pricing

One row per current pricing plan; an app can have several plans.

Columns: pricing_plan_id bigint, app_id bigint, app_handle varchar, app_name varchar | null, type varchar, trial_days integer, price_cents integer, name text, features text, valid_from timestamp

### api.app_change_log

Normalized history of listing, pricing, classification, support, and suggested-keyword changes.

Columns: record_id bigint, app_id bigint, app_handle varchar, field text, value jsonb, valid_from timestamp, valid_to timestamp | null

### api.keyword_rank_history

Every observed validity period for an app's rank for a keyword.

Columns: keyword_rank_id bigint, keyword_id bigint, keyword varchar, app_id bigint, app_handle varchar, app_name varchar | null, rank integer, is_paid boolean, valid_from timestamp, valid_to timestamp | null, is_current boolean

### api.reviews

Current reviews with app, shop, country, and current reply details.

Columns: review_id bigint, shopify_review_id varchar, app_id bigint, app_handle varchar, app_name varchar | null, shop_id bigint, shop_name varchar, country_id bigint, country_name varchar, content text, stars integer, review_date timestamp, reply_id bigint | null, shopify_review_reply_id varchar | null, reply_content text | null, reply_date timestamp | null, valid_from timestamp, reply_valid_from timestamp | null

## Raw tables

The underlying public data tables are readable for queries that need fields or history not represented by these views.

Historical rows are active from valid_from (inclusive) until valid_to (exclusive). A null valid_to means the row is current.

api.current_app, api.current_pricing, and api.reviews already filter to current records. Do not add valid_to filters to those views.

Classification arrays and media arrays in api.current_app, plus value in api.app_change_log, are JSONB.

So `SELECT … FROM app_name WHERE valid_to IS NULL` is the title an app shows
today, and dropping that filter and ordering by `valid_from` gives every
title it has ever shown, with the window each one was live for.

Only the tables behind these views are readable.

## Example queries

### 1. Which apps edited their listing in the last 24 hours

The change log is the product: one row per recorded value, with the window it was live for.

```sql
SELECT app_handle, count(*) AS fields_recorded, max(valid_from) AS latest
FROM api.app_change_log
WHERE valid_from > now() - interval '24 hours'
GROUP BY app_handle
ORDER BY latest DESC
LIMIT 5
```

Live output, run when this page was served (134 ms):

| app_handle | fields_recorded | latest |
| --- | --- | --- |
| trendsi | 1 | 2026-09-01T06:32:14.467Z |
| low-stock-notifier | 1 | 2026-09-01T06:31:24.129Z |
| pixlee | 1 | 2026-09-01T06:30:54.103Z |
| what3words-address-field | 1 | 2026-09-01T06:30:03.980Z |
| instafeed-instagram-feed-tiktok | 1 | 2026-09-01T06:29:54.110Z |

### 2. Who ranks at the top of a keyword's search results right now

Drop `is_current` and filter on valid_from to get the same ranking on any past day.

```sql
SELECT rank, app_handle, app_name
FROM api.keyword_rank_history
WHERE keyword = 'email marketing'
  AND is_current
ORDER BY rank
LIMIT 5
```

Live output, run when this page was served (10 ms):

| rank | app_handle | app_name |
| --- | --- | --- |
| 1 | klaviyo-email-marketing | Klaviyo: Email Marketing & SMS |
| 2 | omnisend | Omnisend Email Marketing & SMS |
| 3 | ecomsend | SendWILL Popup Email Marketing |
| 4 | mailchimp | Mailchimp Email SMS Marketing |
| 5 | emailwiz | Wiz ‑ AI Email Marketing |

### 3. How one app's title has changed, newest first

Raw validity-period tables are readable too. valid_to IS NULL means the row is the value in force now.

```sql
SELECT app_name.name, app_name.valid_from, app_name.valid_to
FROM app_name
JOIN app ON app.id = app_name.app_id
WHERE app.handle = 'pagefly'
ORDER BY app_name.valid_from DESC
LIMIT 5
```

Live output, run when this page was served (6 ms):

| name | valid_from | valid_to |
| --- | --- | --- |
| PageFly ✦ AI Page Builder | 2026-08-24T15:16:36.653Z | NULL |
| PageFly ✦ Landing Page Builder | 2026-08-23T19:52:40.671Z | 2026-08-24T15:16:36.653Z |
| PageFly Landing Page Builder | 2026-08-23T00:00:00.000Z | 2026-08-23T19:52:40.671Z |

## Limits

- Read-only. A single `SELECT` or `WITH` statement per request. Anything else
  is rejected.
- 60 queries per minute per key.
- 2 concurrent queries per key.
- 10,000 rows per query; beyond that the response is truncated.
- 10 second statement timeout.
