URL: /code/data/social/measure-handles · Id: `page:/code/data/social/measure-handles`

# Handle.measure

> Read any public account’s followers, engagement rate and posting cadence from its last 30 posts, before you sponsor it, pitch it or copy it.

POST https://api.sb/&#123;your-domain&#125;/handles/measure

Read any public account’s followers, engagement rate and posting cadence from its last 30 posts, before you sponsor it, pitch it or copy it.

- **Price:** 19¢ per 100 accounts
- **Success rate:** 99.9% of 413,075 calls
- **Freshness:** Fresh for 7 days
- **Emits:** `Handle.measured`

```text title="Run it · Ask"
How big and how active is the YouTube channel @smalllandlordmath? Spend up to 1¢.
```

```ts title="Run it · SDK"
import { $ } from 'startup-builder'

const result = await $.try.Handle.measure({ handles: ['@smalllandlordmath'], venue: 'youtube', maxCost: 0.01 })
```

```json title="Run it · Response"
{
  "measures": [
    {
      "venue": "youtube",
      "handle": "@smalllandlordmath",
      "followers": 41200,
      "following": null,
      "postsRead": 30,
      "engagementRate": 0.046,
      "medianViews": 6800,
      "cadence": {
        "postsPerWeek": 1.5,
        "lastPostAt": "2026-10-23T15:00:00Z"
      },
      "topPosts": [
        {
          "url": "https://www.youtube.com/watch?v=rentroll40",
          "engagement": {
            "views": 61000,
            "reactions": 2400,
            "comments": 310
          }
        }
      ],
      "observedAt": "2026-10-27T14:02:11Z",
      "method": "sampled"
    }
  ],
  "cost": {
    "usd": 0,
    "maxUsd": 0.0019,
    "units": 1,
    "misses": 0
  }
}
```

## Inputs
| Name | Type | Meaning | Price |
| --- | --- | --- | --- |
| `handles` | `string[]` | Up to 25 accounts on one Venue, as `@handle` or a profile link. Required. | Included |
| `venue` | `'instagram' \| 'tiktok' \| 'youtube' \| 'x' \| 'linkedin' \| 'reddit' \| 'facebook'` | Where the accounts are; required when you send bare handles. Default `read from a link`. | Included |
| `venue: 'instagram'` | Option | An account on Instagram | About 19¢ per 100 accounts; works 99.9% of 413,075 calls |
| `venue: 'tiktok'` | Option | An account on TikTok | About 9¢ per 100 accounts; works 100% of 76,265 calls |
| `venue: 'youtube'` | Option | An account on YouTube | About 19¢ per 100 accounts; works 100% of 49,954 calls |
| `venue: 'x'` | Option | An account on X | About 7¢ per 100 accounts; works 99.5% of 270,000 calls |
| `venue: 'linkedin'` | Option | An account on LinkedIn | About 34¢ per 100 accounts; works 99.6% of 35,521 calls |
| `venue: 'reddit'` | Option | An account on Reddit | About 10¢ per 100 accounts; works 98.1% of 1,712 calls |
| `venue: 'facebook'` | Option | An account on Facebook | About 19¢ per 100 accounts; works 94.4% of 76,337 calls |
| `posts` | `number` | Recent posts to average over, at most 100. Under 10, `engagementRate` is null. Default `30`. | Included |
| `maxAge` | `string` | A stored reading younger than this returns at $0 instead of reading again. Default `'7d'`. | Included |
| `maxCost` | `number` | The most this call may spend, in USD. Over it, the call is refused before it spends, or stops early with what it has. Default `0.10`. | Included |

## Outputs
| Field | Type | What `null` means | What it means |
| --- | --- | --- | --- |
| `measures` | `Measure[]` | Never null: one row for each account read; a private or gone account is left out, free. | `lastPostAt` over 30 days ago: The account has gone quiet; its follower count is history. Do not pay for a spot; pick an account that posted this week. *Planning estimate.*<br />Under half the median of the accounts you compared: The audience is bought or tired. Drop it from a sponsorship shortlist. *Planning estimate.*<br />Under 5% of followers on a video Venue: The Venue stopped showing its posts widely. Price a sponsorship from median views, never from followers. *Planning estimate.* |
| `measures[].handle` | `string` | Never null: the account as the Venue names it, `@smalllandlordmath`. | None. |
| `measures[].followers` | `number \| null`, in followers or subscribers | The Venue counts none: Reddit. | None. |
| `measures[].following` | `number \| null` | The Venue shows none: YouTube. | None. |
| `measures[].postsRead` | `number`, in posts | Never null: the posts the averages are over. | None. |
| `measures[].engagementRate` | `number \| null`, in 0 to 1: reactions and comments a post over followers, averaged | Fewer than 10 posts read, or no follower count. | None. |
| `measures[].medianViews` | `number \| null`, in views a post | The Venue shows no views: LinkedIn, Reddit. | None. |
| `measures[].cadence` | `{ postsPerWeek: number; lastPostAt: string \| null }` | Never null. `lastPostAt` is null for an account that never posted. | None. |
| `measures[].topPosts` | `{ url: string; engagement: object }[]` | Never null: the three posts with the most engagement among those read. | None. |
| `measures[]` | [`Observed`](/code/data/types#observed) | Each row also carries `observedAt`, `method` and `venue`. | None. |
| `cost` | [`Cost`](/code/data/types#cost), in USD | Never null. In try mode `usd` is 0 and `maxUsd` is what the call would have cost. | None. |

### On each Venue
| Venue | What comes back | Limits | Price |
| --- | --- | --- | --- |
| Instagram | Followers, following, engagement rate and cadence; `medianViews` from reels. | A private account is skipped, free. | About 19¢ per 100 accounts |
| TikTok | Followers, following, engagement rate, cadence and `medianViews`. | None. | About 9¢ per 100 accounts |
| YouTube | Subscribers as `followers`, engagement rate, cadence and `medianViews`; `following` is null. | None. | About 19¢ per 100 accounts |
| X | Followers, following, engagement rate, cadence and `medianViews` where X shows views. | Replies are left out of cadence; reposts count. | About 7¢ per 100 accounts |
| LinkedIn | Followers, engagement rate and cadence of a member or company page; no views. | The dearest Venue to measure, at about 0.34¢ an account. | About 34¢ per 100 accounts |
| Reddit | Cadence and engagement of an account’s posts and comments; `followers` is null. | Engagement is score per post, never a share of followers. | About 10¢ per 100 accounts |
| Facebook | A page’s followers and cadence; engagement rate from public posts. | Pages only, never personal profiles. | About 19¢ per 100 accounts |
| Hacker News | Nothing: no route reads Hacker News accounts yet. | Refuses with `venue-unsupported`. | None. |

## Effects
**Action class**

None: it reads, so no approval rule names it

**Modes**

- `try`: Runs the read and returns real rows; nothing is saved and nothing is billed.
- `send`: Returns a pending Action at once; its Event carries the rows when the read finishes.
- `do`: Waits for the read and returns the rows, billed per result.

**Idempotency**

The same `Idempotency-Key` with the same input replays the first answer at $0; with other input it returns 409.

**Saved or shared**

Nothing is written to your records. A reading is shared across startups for the same account for 7 days; it reads public accounts only.

**Emits**

`Handle.measured` with `{ venue, measures }`

**Performed by**

[Partner Manager](https://startupbuilder.dev/agents/sales/partner-manager)

**Price**

About 19¢ per 100 accounts. At most 34¢ per 100 accounts. Free: an account that is gone or private, a reading younger than `maxAge`. `maxCost` defaults to 10¢. Ten shortlisted accounts, measured before you pick two: 10 × 0.19¢ an account is about 1.9¢.

**Reliability**

The first route succeeds 99.9% of 413,075 calls. If it fails, the call refuses with `sources-busy` and nothing is spent.

**Freshness**

A reading younger than 7 days returns at $0. Readings are shared across startups for the same subject and locale.

**Batch and pages**

Up to 25 accounts a call. It answers in one response.

**Rate**

[The shared rate rules](/code/data#rate): 10 calls a second per key, and an array input counts as one call.

**Capability**

[Market data](https://startupbuilder.dev/systems/marketing/market-data)

## Refuses when
| Check | Error | Fix | Then call |
| --- | --- | --- | --- |
| A search engine, answer engine or network the call does not read, such as ChatGPT sent to `Keyword.measure` | [`venue-unsupported`](/code/refusals#venue-unsupported) | Pick one the call’s `venue` input lists; AI answers are `Keyword.ask`, posts are `Keyword.search`. | [`Keyword.ask`](/code/data/keywords/ask) |

Every data call can also meet [the shared data-call refusals](/code/refusals#data-calls): [`filter-unsupported`](/code/refusals#filter-unsupported), [`input-missing`](/code/refusals#input-missing), [`limit-exceeded`](/code/refusals#limit-exceeded), [`max-cost`](/code/refusals#max-cost), [`rate-limited`](/code/refusals#rate-limited), [`sources-busy`](/code/refusals#sources-busy).

```json
{
  "type": "https://startupbuilder.dev/errors/venue-unsupported",
  "title": "The call does not read that search engine or network",
  "status": 422,
  "detail": "A search engine, answer engine or network the call does not read, such as ChatGPT sent to `Keyword.measure`.",
  "fix": "Pick one the call’s `venue` input lists; AI answers are `Keyword.ask`, posts are `Keyword.search`.",
  "next": {
    "verb": "Keyword.ask",
    "input": {
      "keyword": "best rent tracking software for a 40-unit property manager",
      "venues": [
        "chatgpt"
      ]
    }
  },
  "instance": "https://api.sb/leaseline.app/actions/action_1"
}
```

## When it waits
Returns in one response; nothing waits.

## Example
A batch: three accounts in one call, one price cap, one `cost`. The private account is left out and costs nothing.

**Ask**

```text
Compare @smalllandlordmath, @rentrollreview and @closedaccount on
YouTube; spend up to 2¢.
```

**API**

```bash
curl -X POST "https://api.sb/leaseline.app/handles/measure?mode=try" \
  -H "Authorization: Bearer $SB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "handles": [
    "@smalllandlordmath",
    "@rentrollreview",
    "@closedaccount"
  ],
  "venue": "youtube",
  "maxCost": 0.02
}'
```

**CLI**

```bash
npx startup-builder do Handle.measure --data '{
  "handles": [
    "@smalllandlordmath",
    "@rentrollreview",
    "@closedaccount"
  ],
  "venue": "youtube",
  "maxCost": 0.02
}'
```

The CLI runs in try mode unless you pass `--do`.

**MCP**

```json
{
  "name": "do",
  "arguments": {
    "mode": "try",
    "code": "return await $.Handle.measure({ handles: ['@smalllandlordmath', '@rentrollreview', '@closedaccount'], venue: 'youtube', maxCost: 0.02 })"
  }
}
```

**SDK**

```ts
import { $ } from 'startup-builder'

const result = await $.try.Handle.measure({
  handles: ['@smalllandlordmath', '@rentrollreview', '@closedaccount'],
  venue: 'youtube',
  maxCost: 0.02,
})
```


```json title="Response, in try mode"
{
  "measures": [
    {
      "venue": "youtube",
      "handle": "@smalllandlordmath",
      "followers": 41200,
      "following": null,
      "postsRead": 30,
      "engagementRate": 0.046,
      "medianViews": 6800,
      "cadence": {
        "postsPerWeek": 1.5,
        "lastPostAt": "2026-10-23T15:00:00Z"
      },
      "topPosts": [
        {
          "url": "https://www.youtube.com/watch?v=rentroll40",
          "engagement": {
            "views": 61000,
            "reactions": 2400,
            "comments": 310
          }
        }
      ],
      "observedAt": "2026-10-27T14:02:11Z",
      "method": "sampled"
    },
    {
      "venue": "youtube",
      "handle": "@rentrollreview",
      "followers": 128000,
      "following": null,
      "postsRead": 30,
      "engagementRate": 0.009,
      "medianViews": 3100,
      "cadence": {
        "postsPerWeek": 0.5,
        "lastPostAt": "2026-09-02T18:00:00Z"
      },
      "topPosts": [
        {
          "url": "https://www.youtube.com/watch?v=rentroll2019",
          "engagement": {
            "views": 22000,
            "reactions": 410,
            "comments": 52
          }
        }
      ],
      "observedAt": "2026-10-27T14:02:11Z",
      "method": "sampled"
    }
  ],
  "cost": {
    "usd": 0,
    "maxUsd": 0.0057,
    "units": 2,
    "misses": 1
  }
}
```

To retry, send the same `Idempotency-Key`: the retry returns the first answer for $0. A new key reads again.

A `sk_test_` key answers these the same way every time, at $0 ([every test value](/code/checks#test-values)).

| Test value | Always returns | What it is for |
| --- | --- | --- |
| `@smalllandlordmath` | `{"followers":41200,"engagementRate":0.046,"postsPerWeek":1.5}` | Measures the same channel every time: 41,200 subscribers, 4.6% engagement, a video and a half a week. |
| `maxCost: 0.0001` | Refuses with [`max-cost`](/code/refusals#max-cost) | Any priced call with a maxCost of 0.0001 is refused before it spends: the price is above the cap. |
| `rate-limited.test` | Refuses with [`rate-limited`](/code/refusals#rate-limited) | Any call naming this hostname answers 429 with Retry-After, so your retry logic runs. |
| `sources-busy.test` | Refuses with [`sources-busy`](/code/refusals#sources-busy) | Any call naming this hostname answers 503 as if every route were busy, and nothing is spent. |

Watch it on a schedule: a Monitor runs this call and records an Event only when the reading changes.

```ts
await $.Monitor.create({
  verb: 'Handle.measure',
  input: { handles: ['@smalllandlordmath'], venue: 'youtube' },
  change: 'engagement-drop',
  threshold: 0.2,
  every: '1w',
})
```

## Used by
None.

## Related
[`Post.find`](/code/data/social/find-posts) · [`Post.findEngagers`](/code/data/social/find-engagers) · [`Handle.find`](/code/data/social/find-handles)