Skip to main content

How to Verify Twitter Engagement Actions via API: Follows, Retweets, Comments, Quotes

Running a social media campaign where users earn rewards for completing actions on X (formerly Twitter), follow an account, retweet a post, leave a comment, join a community, requires a way to verify that each participant actually did what they claimed. Doing this manually does not scale past a handful of users. Doing it with honor-system checkboxes invites bots and fraud. Sorsa API provides a dedicated set of verification endpoints that answer simple yes/no questions: did this user follow that account? Did they retweet this tweet? Did they comment on it? Did they join this community? Each check is a single API call that returns a boolean result, making it straightforward to build automated quest systems, giveaway platforms, referral programs, and engagement campaigns with provable, on-chain-style verification. This guide covers every available verification endpoint with working code, then shows how to combine them into a complete campaign verification pipeline.
Note: For a fuller walkthrough with extra workflows and end-to-end examples, see Twitter Engagement Verification API: Full Campaign Guide on the blog.

Available Verification Checks

Here is what you can verify, which endpoint to use, and what you cannot check:
ActionEndpointMethodWhat it returns
User follows an account/check-followPOST{follow: true/false}
User retweeted a tweet/check-retweetPOST{retweet: true/false}
User quoted a tweet/check-quotedPOST{status: "quoted" / "retweet" / "not_found"}
User commented on a tweet/check-commentGET{commented: true/false}
User is a community member/check-community-memberPOST{is_member: true/false}
What you cannot verify: Likes. X made likes private in 2024, so no API (including the official one) can check whether a specific user liked a specific tweet. Design your campaigns around the five actions above.

Check 1: Did the User Follow an Account?

The most common campaign task. “Follow @YourBrand to enter the giveaway.” Endpoint: POST /v3/check-follow The endpoint’s logic is “does user_2 follow user_1?”. user_1 is the brand (the followed account) and user_2 is the participant.

Simplest Example

curl -X POST https://api.sorsa.io/v3/check-follow \
  -H "ApiKey: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "username_1": "YourBrand",
    "username_2": "participant_handle"
  }'
Response:
{
  "follow": true,
  "user_protected": false
}

Parameters

Provide one identifier for the brand and one for the participant.
ParameterTypeRequiredDescription
username_1stringOne ofThe brand’s handle.
user_link_1stringtheseOr the brand’s profile URL.
user_id_1stringOr the brand’s numeric user ID.
username_2stringOne ofParticipant’s handle.
user_link_2stringtheseOr participant’s profile URL.
user_id_2stringOr participant’s numeric user ID.

Python

import requests

API_KEY = "YOUR_API_KEY"
BASE = "https://api.sorsa.io/v3"
HEADERS = {"ApiKey": API_KEY, "Content-Type": "application/json"}

def check_follow(brand_handle: str, participant_handle: str) -> dict:
    resp = requests.post(
        f"{BASE}/check-follow",
        headers=HEADERS,
        json={"username_1": brand_handle, "username_2": participant_handle},
        timeout=15,
    )
    resp.raise_for_status()
    return resp.json()


result = check_follow("YourBrand", "participant123")
if result["follow"]:
    print("Follow verified.")
elif result.get("user_protected"):
    print("Account is private; follow cannot be confirmed.")
else:
    print("Not following.")
If user_protected is true, the participant’s account is private and their follow relationships cannot be verified.

Check 2: Did the User Retweet a Tweet?

“Retweet this post to enter.” The endpoint scans up to 100 retweets per request and supports pagination for tweets with thousands of retweets. Endpoint: POST /v3/check-retweet

Parameters

ParameterTypeRequiredDescription
tweet_linkstringYesURL of the tweet to verify.
usernamestringOne ofParticipant handle.
user_linkstringtheseOr profile URL.
user_idstringOr numeric user ID.
next_cursorstringNoPagination for tweets with > 100 retweets.

Python

def check_retweet(tweet_link: str, participant_handle: str) -> bool:
    cursor = None
    for _ in range(5):  # check up to 500 retweets total
        body = {"tweet_link": tweet_link, "username": participant_handle}
        if cursor:
            body["next_cursor"] = cursor
        resp = requests.post(f"{BASE}/check-retweet", headers=HEADERS, json=body, timeout=15)
        resp.raise_for_status()
        data = resp.json()
        if data["retweet"]:
            return True
        cursor = data.get("next_cursor")
        if not cursor:
            return False
    return False
Each call scans the most recent 100 retweets. For most campaign verification, one request is sufficient because users typically retweet shortly after the campaign starts and their retweet will be in the most recent batch. For tweets with thousands of retweets where the user retweeted early, paginate through next_cursor.

Check 3: Did the User Quote a Tweet?

“Quote tweet this post with your thoughts.” The /check-quoted endpoint distinguishes between a quote tweet and a plain retweet, returning a status string. Endpoint: POST /v3/check-quoted

Python

def check_quoted(tweet_link: str, participant_handle: str) -> dict:
    resp = requests.post(
        f"{BASE}/check-quoted",
        headers=HEADERS,
        json={"tweet_link": tweet_link, "username": participant_handle},
        timeout=15,
    )
    resp.raise_for_status()
    return resp.json()


data = check_quoted("https://x.com/YourBrand/status/1234567890", "participant123")

if data["status"] == "quoted":
    print(f"Quote verified! They wrote: {data['text']}")
elif data["status"] == "retweet":
    print("They retweeted but did not quote.")
else:
    print("No quote or retweet found.")

Response

{
  "status": "quoted",
  "date": "2026-03-10 14:22:09",
  "text": "This is amazing, everyone should check this out!",
  "user_protected": false
}
The status field returns one of three values: "quoted" (user posted a quote tweet), "retweet" (user retweeted without adding text), or "not_found" (neither action detected). The response also includes the date and text of the quote, which can be used for content quality checks (minimum length, required hashtag, profanity filter).
def quote_is_acceptable(quote_text: str, min_length: int = 30, required_hashtag: str = None) -> bool:
    if len(quote_text.strip()) < min_length:
        return False
    if required_hashtag and required_hashtag.lower() not in quote_text.lower():
        return False
    return True

Check 4: Did the User Comment on a Tweet?

“Leave a comment under this post.” This is the only verification endpoint that uses GET instead of POST. Endpoint: GET /v3/check-comment

Parameters (query string)

ParameterTypeRequiredDescription
tweet_linkstringYesURL of the tweet.
usernamestringOne ofParticipant handle.
user_linkstringtheseOr profile URL.
user_idstringOr numeric user ID.

Python

def check_comment(tweet_link: str, participant_handle: str) -> dict:
    resp = requests.get(
        f"{BASE}/check-comment",
        headers={"ApiKey": API_KEY},
        params={"tweet_link": tweet_link, "username": participant_handle},
        timeout=15,
    )
    resp.raise_for_status()
    return resp.json()


data = check_comment("https://x.com/YourBrand/status/1234567890", "participant123")

if data["commented"]:
    print(f"Comment verified: {data['tweet']['full_text'][:100]}")
else:
    print("No comment found.")
When commented is true, the response includes the full tweet object of the comment itself, with text, engagement metrics, and timestamp. Use this to enforce comment quality (minimum length, required hashtag, no emoji-only replies) beyond just checking existence.
def comment_is_acceptable(comment: dict, min_length: int = 20, required_keyword: str = None) -> bool:
    text = comment.get("full_text", "").strip()
    if len(text) < min_length:
        return False
    if required_keyword and required_keyword.lower() not in text.lower():
        return False
    if len(text.split()) < 3:
        return False
    return True

Check 5: Is the User a Community Member?

“Join our X Community to participate.” Useful for campaigns that require community membership as a prerequisite. Endpoint: POST /v3/check-community-member

Python

def check_community_member(community_id: str, participant_handle: str) -> bool:
    resp = requests.post(
        f"{BASE}/check-community-member",
        headers=HEADERS,
        json={"community_id": community_id, "username": participant_handle},
        timeout=15,
    )
    resp.raise_for_status()
    return resp.json().get("is_member", False)


is_member = check_community_member("1966045657589813686", "participant123")
print("Member" if is_member else "Not a member")
The community ID is the numeric string in the community URL (x.com/i/communities/<id>).

Building a Campaign Verification Pipeline

In a real campaign, users complete multiple tasks. The following pattern runs all five checks for a single participant, returns a structured result, and applies quality rules to the comment and quote.
from dataclasses import dataclass, field

@dataclass
class CampaignConfig:
    brand_handle: str
    tweet_to_retweet: str
    tweet_to_quote: str
    tweet_to_comment: str
    community_id: str
    required_hashtag: str = ""
    min_quote_length: int = 30
    min_comment_length: int = 20

@dataclass
class ParticipantResult:
    username: str
    follow: bool = False
    retweet: bool = False
    quote: bool = False
    quote_text: str = ""
    comment: bool = False
    comment_text: str = ""
    community: bool = False
    completed: int = field(init=False, default=0)

    def total(self) -> int:
        return sum([self.follow, self.retweet, self.quote, self.comment, self.community])


def verify_participant(username: str, cfg: CampaignConfig) -> ParticipantResult:
    r = ParticipantResult(username=username)

    r.follow = check_follow(cfg.brand_handle, username)["follow"]
    r.retweet = bool(check_retweet(cfg.tweet_to_retweet, username))

    quote_data = check_quoted(cfg.tweet_to_quote, username)
    if quote_data["status"] == "quoted":
        r.quote_text = quote_data.get("text", "")
        r.quote = quote_is_acceptable(r.quote_text, cfg.min_quote_length, cfg.required_hashtag)

    comment_data = check_comment(cfg.tweet_to_comment, username)
    if comment_data.get("commented"):
        r.comment_text = comment_data["tweet"].get("full_text", "")
        r.comment = comment_is_acceptable(comment_data["tweet"], cfg.min_comment_length)

    r.community = check_community_member(cfg.community_id, username)

    r.completed = r.total()
    return r


cfg = CampaignConfig(
    brand_handle="YourBrand",
    tweet_to_retweet="https://x.com/YourBrand/status/111111111",
    tweet_to_quote="https://x.com/YourBrand/status/222222222",
    tweet_to_comment="https://x.com/YourBrand/status/333333333",
    community_id="1966045657589813686",
    required_hashtag="#YourLaunch",
)

result = verify_participant("participant123", cfg)
print(f"@{result.username}: {result.completed}/5 tasks done")
A single participant costs 5 API requests (one per task). At Sorsa’s 20 req/s rate limit, one worker thread sequentially verifies roughly 4 participants per second.

Verifying Participants in Bulk

When a campaign has thousands of participants, verify them in batch. The following pattern handles rate limits, writes results to CSV, and is resumable (it writes a row after each participant so a crash does not lose progress).
import csv
import time
from pathlib import Path

def verify_campaign_batch(usernames: list[str], cfg: CampaignConfig, output_file: str) -> None:
    fields = ["username", "follow", "retweet", "quote", "comment", "community",
              "completed", "quote_text", "comment_text"]

    already_done = set()
    out_path = Path(output_file)
    if out_path.exists():
        with out_path.open() as f:
            already_done = {row["username"] for row in csv.DictReader(f)}

    mode = "a" if out_path.exists() else "w"
    with out_path.open(mode, newline="") as f:
        writer = csv.DictWriter(f, fieldnames=fields)
        if mode == "w":
            writer.writeheader()

        for i, username in enumerate(usernames):
            if username in already_done:
                continue
            try:
                r = verify_participant(username, cfg)
                writer.writerow({
                    "username": r.username,
                    "follow": r.follow,
                    "retweet": r.retweet,
                    "quote": r.quote,
                    "comment": r.comment,
                    "community": r.community,
                    "completed": r.completed,
                    "quote_text": r.quote_text,
                    "comment_text": r.comment_text,
                })
                f.flush()
                print(f"[{i+1}/{len(usernames)}] @{username}: {r.completed}/5")
            except requests.HTTPError as e:
                if e.response.status_code == 429:
                    time.sleep(5)
                    continue
                print(f"[{i+1}] @{username}: ERROR {e}")

            time.sleep(0.25)


participants = open("entries.txt").read().splitlines()
verify_campaign_batch(participants, cfg, "campaign_results.csv")
Each participant requires 5 API calls. At Sorsa’s 20 req/s rate limit, this pattern verifies about 4 participants per second sequentially, or roughly 14,000 per hour. The time.sleep(0.25) keeps the call rate safely under the limit.

Account Ownership Verification

Before a user can participate in a campaign, you may want to prove they actually own the X handle they provided. A common pattern:
  1. Generate a unique code (e.g., VERIFY-a8f3b2) and show it to the user.
  2. Ask them to post a tweet containing that code.
  3. Use /user-tweets to fetch their recent tweets and check if the code appears.
import secrets

def generate_verification_code() -> str:
    return f"VERIFY-{secrets.token_hex(4)}"


def verify_account_ownership(username: str, expected_code: str) -> bool:
    resp = requests.post(
        f"{BASE}/user-tweets",
        headers=HEADERS,
        json={"link": f"https://x.com/{username}"},
        timeout=15,
    )
    resp.raise_for_status()
    tweets = resp.json().get("tweets", [])

    for tweet in tweets:
        if expected_code in tweet.get("full_text", ""):
            return True
    return False


code = generate_verification_code()
print(f"Ask the user to tweet: {code}")
# ... after user tweets ...
if verify_account_ownership("participant123", code):
    print("Account ownership confirmed.")
The participant can delete the tweet after verification since only a one-time confirmation is needed.

Anti-Fraud Considerations

Automated campaigns attract bots. A few API-level checks rule out the obvious offenders:
  • Minimum account age. Fetch the participant’s profile via /info and check created_at. Reject accounts created in the last 30 days, since most bot farms use fresh accounts.
  • Minimum activity. Check tweets_count and followers_count. An account with 0 tweets and 2 followers is almost certainly not a real participant.
  • Comment quality. When verifying comments via /check-comment, the response includes the full tweet text. Check for minimum length, presence of required keywords or hashtags, and reject single-character or emoji-only replies.
  • Quote quality. The /check-quoted response includes the quote text. Apply the same quality checks as for comments.
  • Rate of completion. If a user completes all 5 tasks within seconds of receiving the task list, that is a bot. Log timestamps and flag suspiciously fast completions.
from datetime import datetime, timezone

def is_legitimate_account(
    username: str,
    min_age_days: int = 30,
    min_tweets: int = 10,
    min_followers: int = 5,
) -> tuple[bool, dict]:
    resp = requests.get(
        f"{BASE}/info",
        headers={"ApiKey": API_KEY},
        params={"username": username},
        timeout=15,
    )
    resp.raise_for_status()
    profile = resp.json()

    created = datetime.fromisoformat(profile["created_at"].replace("Z", "+00:00"))
    age_days = (datetime.now(timezone.utc) - created).days

    checks = {
        "account_age_ok": age_days >= min_age_days,
        "has_tweets": profile.get("tweets_count", 0) >= min_tweets,
        "has_followers": profile.get("followers_count", 0) >= min_followers,
        "not_protected": not profile.get("protected", False),
    }
    return all(checks.values()), checks
Apply this check before running the five verification checks. If is_legitimate_account returns False, you save 5 verification requests on a participant you would have rejected anyway.

Scoring Participants by Influence

Not all participants have equal reach. A retweet from an account with 50,000 followers is worth more to a campaign than one from an account with 50. Use the /info endpoint to fetch the participant’s profile and weight their reward by follower count.
import math

BASE_POINTS = {"follow": 10, "retweet": 15, "quote": 25, "comment": 20, "community": 10}

def get_follower_count(username: str) -> int:
    resp = requests.get(
        f"{BASE}/info",
        headers={"ApiKey": API_KEY},
        params={"username": username},
        timeout=15,
    )
    resp.raise_for_status()
    return resp.json().get("followers_count", 0)


def calculate_weighted_points(result: ParticipantResult) -> dict:
    followers = get_follower_count(result.username)
    # log scaling: 100 followers -> 2x, 10K -> 4x, 1M -> 6x
    multiplier = max(1.0, math.log10(followers + 1))
    total = 0
    breakdown = {}
    for task, base in BASE_POINTS.items():
        if getattr(result, task):
            points = round(base * multiplier)
            breakdown[task] = points
            total += points
    return {"followers": followers, "multiplier": round(multiplier, 2),
            "breakdown": breakdown, "total": total}
For crypto-focused campaigns, replace the follower-count multiplier with Sorsa Score, which measures recognition from crypto KOLs, projects, and VCs.

A Note on Likes

X (Twitter) made likes private in 2024. The platform no longer exposes which users liked a specific tweet through any public API, not Sorsa, not the official X API, not any third-party tool. If a campaign previously included a “Like this tweet” task, replace it with a retweet or comment requirement, both of which remain fully verifiable.

Next Steps

  • Search Tweets, find campaign-related tweets by keyword for broader monitoring.
  • Track Mentions, track organic mentions of your brand alongside campaign-driven mentions.
  • Real-Time Monitoring, verify tasks in near-real-time by polling for new activity.
  • Followers & Following, extract your own follower list to cross-reference with campaign participants.
  • Pricing, estimate campaign costs (5 requests per participant for full verification).
  • API Reference, full specification for all verification endpoints.