Tools

LiveSensitiveSpends credits

Find when an X account should post

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

  • handlestringrequired

    X handle. Accepts a bare name, @name, or a profile URL. Also accepted as `username`.

Example request body
{
  "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
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"}'
JavaScript
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();
Python
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
200 POST /tools/best-posting-time
{
  "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_idstringrequired

    Unique id for this request. Quote it in a support ticket.

  • generated_atstringrequired

    Server time the response was produced.

  • took_msintegerrequired

    Milliseconds spent server-side.

  • pagePageoptional
  • sourcestringoptional

    Which backend served the payload, for endpoints with more than one.

  • cache_age_sintegeroptional

    Age 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.

  • 401Unauthorizedunauthorized | invalid_key

    No key was presented, or the key is unknown, revoked or expired.

  • 402PaymentRequiredpayment_required | subscription_inactive

    The 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.

  • 403Forbiddenforbidden_scope | forbidden_ip

    The 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.

  • 422InvalidRequestinvalid_request

    A parameter is malformed, out of range or mutually exclusive with another. `details` names the offending fields.

  • 429RateLimitedrate_limited | quota_exceeded

    Either 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.

  • 500InternalErrorinternal_error

    Something failed on our side. Internals are never leaked; quote the request id.

  • 503Unavailableupstream_timeout | upstream_error | not_configured

    The 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.

X
TierRequests a minuteRequests a dayLive reads a minute
free301,0005
standard12025,00020
pro600250,00060
unlimited6,00010,000,000600

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

We value your privacy

We use cookies to improve our site, analyze traffic, and personalize ads. You can accept all, reject non-essential, or customize your choices. Read our Cookie Policy.