Docs
Dashboard

X (Twitter) Commerce

Sell products, receive tips, and earn affiliate revenue directly from X (Twitter). These endpoints power the merchant X Commerce dashboard: linking an X handle, connection state, tip and social-sale aggregates, affiliate revenue, and buy-button ("payment post") generation.

Authentication

All X Commerce endpoints except the public creator profile (GET /x/pay/:username) and the public payment-link generator (POST /x/pay/:username/link) require a valid JWT sent as Authorization: Bearer <token>. The base URL for all requests is https://gullypay.xyz/api. Tips and social sales are persisted as real graph_edges rows and aggregated per creator from that data.

X ownership is verified via real OAuth 2.0 (PKCE)

Connect with X runs a real X OAuth 2.0 authorization-code flow with PKCE. The handle is read from X itself (GET /2/users/me) after the token exchange — never from client input — so a connection proves the user controls that X account. Handles linked this way carry verified: true. A handle already linked to another account is rejected with 409.

POST /x/link remains as an unverified self-service fallback for when OAuth is not configured: it attaches a handle to the authenticated user's own account (owner id/email come from the JWT, never the body), marked verified: false. Prefer the OAuth flow.

Configuration

The OAuth endpoints require X_CLIENT_ID and X_CLIENT_SECRET (an X developer OAuth 2.0 confidential app) plus X_CALLBACK_URL (must match a registered redirect URI — https://gullypay.xyz/api/x/oauth/callback in production). When these are absent, GET /x/oauth/start and the callback fail cleanly with 400 "X OAuth is not configured" — the platform never fakes a successful link. Requested scopes: users.read tweet.read offline.access.

Endpoints

Connection & Onboarding

GET/x/oauth/startBegin the real X OAuth 2.0 (PKCE) flow; returns the X authorize URL (auth required)
GET/x/oauth/callbackX OAuth redirect target; verifies the handle and links it, then redirects to the dashboard (no auth — user resolved from state)
POST/x/linkUnverified self-service link fallback (auth required)
GET/x/me/connectionCurrent X connection state incl. verified flag + real public-metric stats (auth required)
POST/x/me/stats/refreshRefresh the linked handle's public follower/post counts from the X API (auth required)
POST/x/me/disconnectUnlink the X handle from the authenticated user (auth required)
GET/x/me/affiliateAffiliate revenue summary for the authenticated user (auth required)

Tips & Aggregates

POST/x/tipSend a tip to a creator by X username (auth required)
GET/x/tips/:username/statsTip count + total + recent tips received by a creator (auth required)
GET/x/tips/:usernameRecent tips received by a creator (auth required)
GET/x/creators/:username/top-tippersTop tippers for a creator, aggregated from tip edges (auth required)
GET/x/creators/:username/social-salesRecent X-attributed social sales for a creator (auth required)
GET/x/creators/:username/activityDaily tips + sales + affiliate time-series (auth required)
POST/x/buy-buttonGenerate a buy button / payment post for a product (auth required)
GET/x/pay/:usernamePublic creator pay profile
POST/x/pay/:username/linkGenerate a public payment link for a creator

Connect with X (OAuth 2.0 + PKCE)

GET/x/oauth/startBegin the OAuth flow

Call with the user's JWT. The response contains the X authorize URL; redirect the browser to it. The server generates and persists a CSRF state and a PKCE verifier/challenge against the user's record.

Response
{
"authorizeUrl": "https://twitter.com/i/oauth2/authorize?response_type=code&client_id=...&redirect_uri=...&scope=users.read%20tweet.read%20offline.access&state=...&code_challenge=...&code_challenge_method=S256",
"state": "…"
}
GET/x/oauth/callbackOAuth redirect target (X → GullyPay)

X redirects the browser here with ?code=…&state=…. The server resolves the initiating user from state, exchanges the code at https://api.twitter.com/2/oauth2/token (PKCE + Basic auth), reads the verified handle and public_metrics from GET /2/users/me, links that handle to the user (verified: true), then 302-redirects the browser back to …/x-commerce?x_connected=1&handle=<handle>. On failure it redirects with ?x_error=<message>. Access tokens are used transiently and never persisted. A handle already owned by another account is rejected with 409.

What stats are real

On a successful OAuth connect the response captures a real snapshot of followers, following, and posts (public_metrics.tweet_count) from X. POST /x/me/stats/refresh re-pulls these live using the app-only X_BEARER_TOKEN (when configured; otherwise returns available: false). Engagement rate and link clicks are not exposed by the X API and are intentionally left unavailable (—) rather than fabricated.

POST/x/linkLink an X handle to the authenticated user
Request Body
{
"xUsername": "gullypay",
"xUserId": "1234567890"
}
FieldTypeRequiredDescription
xUsernamestringYesThe X handle to link (a leading @ is stripped)
xUserIdstringNoThe numeric X user id, if known

The owning user id and email come from the JWT, not the body. Linking is idempotent: it ensures the user has a wallet, an affiliate code, and a commerce-graph node so inbound tips can be aggregated.

Response
{
"connected": true,
"xUsername": "gullypay",
"xUserId": "1234567890",
"email": "[email protected]",
"wallet": { "id": "wal_1", "ownerId": "usr_1", "ownerType": "user", "currency": "USD" },
"affiliate": { "id": "aff_1", "code": "gullypay", "commissionRate": 10 },
"payUrl": "https://pay.gullypay.xyz/gullypay",
"profileUrl": "https://x.com/gullypay"
}

Connection State

GET/x/me/connectionCurrent X connection state
Response
{
"connected": true,
"xUsername": "gullypay",
"xUserId": "1234567890",
"verified": true,
"connectedAt": "2026-07-18T12:00:00Z",
"profileUrl": "https://x.com/gullypay",
"payUrl": "https://pay.gullypay.xyz/gullypay",
"stats": { "followers": 12000, "following": 340, "posts": 5100, "fetchedAt": "2026-07-18T12:00:00Z" }
}

verified is true only when the handle was connected via the OAuth flow. stats is a real snapshot from the X API public_metrics, or null until a real fetch has succeeded (the UI shows —, never fabricated numbers). When no handle is linked, connected is false and the string fields are null.

Refresh Public Stats

POST/x/me/stats/refreshPull the linked handle's public metrics from the X API

Gated by X_BEARER_TOKEN (app-only bearer). Returns the refreshed snapshot, or an honest { "available": false, "reason": "not_configured" | "not_connected" | "x_api_error" | "no_metrics", "stats": null } when live stats can't be sourced.

Response
{
"available": true,
"reason": null,
"stats": { "followers": 12000, "following": 340, "posts": 5100, "fetchedAt": "2026-07-18T12:00:00Z" }
}

Affiliate Summary

GET/x/me/affiliateAffiliate revenue summary

Real figures from the affiliates ledger plus a referral count. Returns a zeroed summary when the user has no affiliate record.

Response
{
"code": "gullypay",
"totalEarned": 24500,
"totalPaid": 12000,
"referralCount": 8,
"commissionRate": 10
}

Tips

POST/x/tipSend a tip to a creator
Request Body
{
"toUsername": "gullypay",
"amount": 500,
"currency": "USD"
}
FieldTypeRequiredDescription
toUsernamestringYesRecipient creator's X handle
amountnumberYesTip amount in minor units (cents)
currencystringYesISO currency code

Each tip is persisted as a real graph_edges row (edgeType = tip, weight = amount) targeting the recipient's graph node, so all tip aggregates below are computed from real data.

Top Tippers

GET/x/creators/:username/top-tippersTop tippers for a creator

Aggregates tip edges grouped by sender, resolved back to a display handle, ordered by total tipped. Optional ?limit= (default 5). Returns [] until real tips exist.

Response
[
{ "handle": "@supporter", "amount": 3200, "count": 6, "avatar": "SU" }
]

Social Sales

GET/x/creators/:username/social-salesRecent X-attributed social sales

Reads X-attributed sales persisted as x_sale graph edges. This is a real query that returns an honest empty list ([]) until sales are attributed to X — the platform does not fabricate rows.

Response
[
{ "id": "edge_1", "product": "Founders Pass", "tweet": "Minting now", "amount": 5000, "buyer": "buyer", "createdAt": "2026-07-18T12:00:00Z" }
]

Activity Time-Series

GET/x/creators/:username/activityDaily tips + sales + affiliate series

Optional ?days= (1–90, default 7). Every series is a real DB aggregation; missing days are zero-filled so a chart can render a continuous axis.

Response
[
{ "d": "07/18", "date": "2026-07-18", "tips": 1200, "sales": 0, "affiliate": 300 }
]

Buy Buttons

POST/x/buy-buttonGenerate a buy button / payment post for a product
Request Body
{
"merchantId": "merch_abc123",
"productId": "prod_abc123"
}
Response
{
"embedCode": "<script src=\"https://checkout.gullypay.xyz/button.js\" data-merchant=\"merch_abc123\" data-product=\"prod_abc123\"></script>",
"directUrl": "https://checkout.gullypay.xyz/merch_abc123/prod_abc123"
}

The merchant dashboard's "+ Create Payment Post" action calls this endpoint with a product picked from the merchant's real catalog and shows the returned direct link and embed code.

Next Steps

  • Commerce Graph -- The graph that stores tip and sale edges
  • CRM & Loyalty -- The affiliate/referral network behind affiliate revenue
  • Merchants -- Create the merchant that owns buy buttons