Tools
LiveSensitiveSpends creditsFind when an X account should post
/data-api/v2/tools/best-posting-time- Scope
- tools:use
- Freshness
- live read
- Group
- Tools
- Platforms
- 1 of 7
What this endpoint answers
Builds a 7x24 engagement heatmap from an account's post history, estimates its posting timezone, and returns the best and worst slots, per-day and per-hour summaries, the posting pattern (cadence, consistency, weekday versus weekend), and a 0-100 timing score.
This is a live read: it goes to the platform at request time, returns cache_age_s = 0, and is metered against a separate live-per-minute bucket on top of your tier. It needs the tools:use scope (Tools): Run the analysis tools: valuation, follower audit, scoring, and more.
Good to know
- Costs money: this endpoint bills twitterapi.io credits per call. Metered as a live call.
- Returns 503 not_configured when TWITTERAPI_IO_KEY is unset, and 502 upstream_error when the credit pool is exhausted or the upstream fails. It never returns an empty 200.
- Ignores NEXT_PUBLIC_USE_MOCK_DATA: the X tools always read live data and have no mock path.
- The website's captcha and per-visitor daily caps do not apply here; your API key's tier limits do.
- This tool pages the post history and can spend up to five upstream calls, more than any other X tool here.
- The timezone is inferred from posting behaviour, not from the account. `heatmap` hours are in that inferred timezone.
- heatmap contains only slots the account has actually posted in, not a padded 168-cell grid.
Parameters
This endpoint takes no path or query parameters.
Request body
handlestringrequiredX handle. Accepts a bare name, @name, or a profile URL. Also accepted as `username`.
{
"handle": "elonmusk"
}
Call it
Authenticate with a bearer token or the x-api-key header. Keys are server-to-server credentials. Never embed one in front-end code - call the API from your own backend and forward the result.
curl -X POST "https://crmsolid.com/data-api/v2/tools/best-posting-time" \
-H "Authorization: Bearer psk_live_..." \
-H "Content-Type: application/json" \
-d '{"handle":"elonmusk"}'
const res = await fetch("https://crmsolid.com/data-api/v2/tools/best-posting-time", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CRM_SOLID_DATA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"handle": "elonmusk"
}),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message} (${error.request_id})`);
}
const { data, meta } = await res.json();
import json
import os
import requests
body = json.loads("""
{
"handle": "elonmusk"
}
""")
res = requests.post(
"https://crmsolid.com/data-api/v2/tools/best-posting-time",
headers={"Authorization": f"Bearer {os.environ['CRM_SOLID_DATA_API_KEY']}"},
json=body,
timeout=30,
)
res.raise_for_status()
payload = res.json()
data, meta = payload["data"], payload["meta"]
Keys look like psk_live_... for production keys, psk_test_... for test keys and are minted in the panel.
What comes back
Success is { data, meta }. Failure is { error: { code, message, request_id } }. The body below is the spec's own example: the values in it are illustrative readings, not live numbers.
- Profile handle
- swyx
- Profile twitter id
- 33521530
- Profile name
- swyx
- Profile avatar url
- https://pbs.twimg.com/profile_images/1610205747724443648/0DVR0Xnw.jpg
- Profile verified
- yes
- Profile followers
- 118,420
- Profile following
- 2,412
- Profile total posts
- 41,208
- Profile created at
- 4/19/2009, 3:02:33 PM
- Profile account age days
- 6,335
- Posts analyzed
- 180
- Date range from
- 5/2/2026, 11:20:00 AM
- Date range to
- 8/22/2026, 6:44:00 PM
- Timezone estimated
- UTC-8 (US Pacific)
- Timezone utc offset
- -8
- Heatmap
- 1 items
- Best times
- 1 items
- Worst times
- 1 items
- Best day day
- 2
- Best day day name
- Tuesday
- Best day posts
- 31
- Best day avg engagement
- 366
- Best day avg views
- 24,800
- Best day total engagement
- 11,346
- Worst day day
- 0
- Worst day day name
- Sunday
- Worst day posts
- 12
- Worst day avg engagement
- 92
{
"data": {
"profile": {
"handle": "swyx",
"twitter_id": "33521530",
"name": "swyx",
"avatar_url": "https://pbs.twimg.com/profile_images/1610205747724443648/0DVR0Xnw.jpg",
"verified": true,
"followers": 118420,
"following": 2412,
"total_posts": 41208,
"created_at": "2009-04-19T15:02:33.000Z",
"account_age_days": 6335
},
"posts_analyzed": 180,
"date_range": {
"from": "2026-05-02T11:20:00.000Z",
"to": "2026-08-22T18:44:00.000Z"
},
"timezone": {
"estimated": "UTC-8 (US Pacific)",
"utc_offset": -8
},
"heatmap": [
{
"day": 2,
"hour": 9,
"posts": 14,
"avg_engagement": 412,
"avg_likes": 318,
"avg_retweets": 44,
"avg_replies": 50,
"avg_views": 28400,
"total_engagement": 5768
}
],
"best_times": [
{
"day": 2,
"day_name": "Tuesday",
"hour": 9,
"hour_label": "9:00 AM",
"avg_engagement": 412,
"avg_views": 28400,
"posts": 14
}
],
"worst_times": [
{
"day": 6,
"day_name": "Saturday",
"hour": 23,
"hour_label": "11:00 PM",
"avg_engagement": 38,
"avg_views": 2900,
"posts": 4
}
],
"best_day": {
"day": 2,
"day_name": "Tuesday",
"posts": 31,
"avg_engagement": 366,
"avg_views": 24800,
"total_engagement": 11346
},
"worst_day": {
"day": 0,
"day_name": "Sunday",
"posts": 12,
"avg_engagement": 92,
"avg_views": 7100,
"total_engagement": 1104
},
"best_hour": {
"hour": 9,
"posts": 27,
"avg_engagement": 388,
"avg_views": 26100,
"total_engagement": 10476
},
"worst_hour": {
"hour": 23,
"posts": 6,
"avg_engagement": 41,
"avg_views": 3100,
"total_engagement": 246
},
"day_summaries": [],
"hour_summaries": [],
"pattern": {
"posts_per_day": 1.6,
"posts_per_week": 11.2,
"most_active_day": "Tuesday",
"most_active_hour": "9:00 AM",
"consistency": "consistent",
"avg_hours_between_posts": 14.8,
"weekday_vs_weekend": {
"weekday_avg_engagement": 318,
"weekend_avg_engagement": 121,
"weekday_posts": 142,
"weekend_posts": 38,
"winner": "weekday",
"difference": 162.8
}
},
"engagement": {
"engagement_rate": 0.31,
"avg_likes": 284,
"avg_retweets": 38,
"avg_replies": 44,
"avg_views": 21400
},
"timing_score": 74,
"timing_grade": "good",
"summary": {
"recommendations": [
"Tuesday 9:00 AM is this account's strongest slot by a wide margin.",
"Weekend posts earn 62% less engagement. Move them into the week."
]
}
},
"meta": {
"request_id": "req_9f2c41a8b3d5",
"generated_at": "2026-08-23T09:14:02.317Z",
"took_ms": 42,
"cache_age_s": 0
}
}
The meta block
request_idstringrequiredUnique id for this request. Quote it in a support ticket.
generated_atstringrequiredServer time the response was produced.
took_msintegerrequiredMilliseconds spent server-side.
pagePageoptionalsourcestringoptionalWhich backend served the payload, for endpoints with more than one.
cache_age_sintegeroptionalAge of the underlying data in seconds. 0 for live reads.
When it fails
POST /tools/best-posting-time documents 7 failure statuses. Branch on error.code, which is stable and enumerated; message is prose and may change.
- 401Unauthorized
unauthorized | invalid_keyNo key was presented, or the key is unknown, revoked or expired.
- 402PaymentRequired
payment_required | subscription_inactiveThe key is valid but the plan behind it cannot serve the call: the included requests are spent and overage is switched off, capped or unfunded (payment_required), or the billing period lapsed and was not renewed (subscription_inactive). Retrying does not help; paying does. The X-Plan-* headers on this response say how far past the line you are.
Carries X-Plan, X-Plan-Limit, X-Plan-Overage, X-Plan-Period-End, X-Plan-Remaining.
- 403Forbidden
forbidden_scope | forbidden_ipThe key is valid but not allowed to make this call: it lacks the scope, or the request came from an address outside the key's allowlist.
- 422InvalidRequest
invalid_requestA parameter is malformed, out of range or mutually exclusive with another. `details` names the offending fields.
- 429RateLimited
rate_limited | quota_exceededEither the burst ceiling for the current minute or the daily quota is spent. Distinguish with the code: rate_limited clears within the minute, quota_exceeded does not clear until 00:00 UTC.
Carries Retry-After, X-Quota-Limit, X-Quota-Remaining, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-Request-Id.
- 500InternalError
internal_errorSomething failed on our side. Internals are never leaked; quote the request id.
- 503Unavailable
upstream_timeout | upstream_error | not_configuredThe request could not be served right now. BRANCH ON error.code, not on the status: 'upstream_timeout' means a source was too slow (this is what a catalog query hitting its 15-second statement timeout returns, so it is reachable from any endpoint that reads the corpus, not only the live-scrape ones) and the same call is worth retrying with backoff - narrowing it with a smaller limit, a filtered scope or a less popular account makes it far less likely; 'upstream_error' means a source was unreachable, so back off further; 'not_configured' means the capability has no backing service in this deployment, and retrying will never help.
Every response carries X-Request-Id and meta.request_id. Quote it in support requests.
Coverage and limits
This endpoint covers X. Rate limits come from the tier on your key.
| Tier | Requests a minute | Requests a day | Live reads a minute |
|---|---|---|---|
| free | 30 | 1,000 | 5 |
| standard | 120 | 25,000 | 20 |
| pro | 600 | 250,000 | 60 |
| unlimited | 6,000 | 10,000,000 | 600 |
Live reads count against the live column as well as the ordinary one, so a burst of them runs out of the live bucket first. Every response reports where you stand in X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset and the X-Plan headers.
Start calling it
A key takes a minute to mint in the panel, no card. The reference covers authentication, the envelope, scopes, rate limits and every error code in one page.
Reference path: /data-api/reference/tools-best-posting-time