When a product launches, the best praise it gets is scattered across X: a thread here, a demo video there, a quote-post with a screenshot. A week later it's buried in the timeline.
We wanted one page that collects those posts, shows them properly and links back to each original. So we built an Import from X feature into Wallsy, our testimonial tool, and tested it on a real launch: the Jev Wall of Love, which at the time of writing holds 13 posts in three languages with more than 33,000 likes between them.
This post covers how the import works under the hood: parsing the URL, calling the X API v2, cleaning up the response, and publishing it safely.
The goal: one paste, one published card
The user flow is deliberately small:
- Open the Inbox, choose Import from X.
- Paste
https://x.com/name/status/1234567890. - Click Import and publish.
Behind that button we need the post text, media, author name, handle and avatar, and the like/reply counts, all turned into a testimonial card that appears on the wall and in any embedded widgets.
Step 1: Get the post ID out of whatever URL people paste
People paste x.com, twitter.com, mobile.twitter.com, /i/web/status/... links, and links with ?s=20 tracking params. One regex handles all of them and returns only the numeric ID:
/** The numeric post id from an x.com / twitter.com status URL, or null. */
public static function postIdFromUrl(string $url): ?string
{
$url = trim($url);
if (! preg_match(
'#^https?://(?:www\.|mobile\.)?(?:x|twitter)\.com/(?:[A-Za-z0-9_]{1,15}|i/web)/status(?:es)?/(\d{5,25})(?:[/?\#].*)?$#i',
$url,
$m
)) {
return null;
}
return $m[1];
}
Anchoring the pattern (^...$) and allowing only known hosts means we never send arbitrary user input to the X API, just a validated numeric ID.
Step 2: One API call per post
The X API v2 can return the post, its author and its media in a single request if you ask for the right expansions:
$response = Http::withToken(config('services.x.bearer_token'))
->acceptJson()
->timeout(10)
->get('https://api.x.com/2/tweets/'.$postId, [
'expansions' => 'author_id,attachments.media_keys',
'tweet.fields' => 'created_at,public_metrics,entities,attachments,note_tweet',
'user.fields' => 'name,username,profile_image_url',
'media.fields' => 'type,url,preview_image_url,width,height,alt_text',
]);
A few things worth knowing if you build something similar:
-
note_tweetmatters. Long posts are truncated intext. The full body lives innote_tweet.text, so we prefer that when it's present. -
Authors and media come back in
includes, not inline. You join them back byauthor_idandmedia_key. -
Videos and GIFs have no
url, only apreview_image_url. We store the preview and link the play button to the original post. -
Errors aren't always non-2xx. For a deleted, private or unknown post, X can answer
200with anerrorsarray and nodata. Check fordata, not just the status code.
We map the status codes to messages a non-technical user can act on:
if ($response->status() === 429) {
throw new XApiException('X is limiting requests right now. Please try again in a few minutes.', 429);
}
if (in_array($response->status(), [401, 403], true)) {
// Credentials problem: log it for us, show a generic message to the user.
Log::warning('X API rejected the credentials', ['status' => $response->status()]);
throw new XApiException('Wallsy couldn't connect to X. Please try again later.', 503);
}
$data = $response->json('data');
if ($response->failed() || ! is_array($data)) {
throw new XApiException('That post couldn't be found. It may be deleted, private or from a protected account.', 404);
}
Note that a 401/403 never leaks to the user as "your token is invalid". That's our problem, not theirs, so it's logged and reported as a generic 503.
Step 3: Clean up the response
Two small fixes make the cards look right.
Bigger avatars. profile_image_url points at a 48px _normal image, which looks blurry on a card. The 400px version lives at the same path:
str_replace('_normal.', '_400x400.', $author['profile_image_url']);
Readable links. Post text contains t.co short links, plus a trailing t.co link for attached media. We swap each short link for its display_url and drop the media ones, since the media is rendered separately:
foreach ($urls as $u) {
$replacement = isset($u['media_key'])
? ''
: ($u['display_url'] ?? $u['expanded_url'] ?? $u['url']);
$text = str_replace($u['url'], $replacement, $text);
}
return trim(html_entity_decode($text, ENT_QUOTES | ENT_HTML5));
html_entity_decode is there because X returns & and friends in post text. The output is still escaped when it's rendered, so decoding here doesn't open an XSS hole.
Step 4: Fetch once, reuse everywhere
X API access is rate-limited and paid, so we store each fetched post in its own x_posts table, keyed by the post ID. If anyone imports the same post again, we reuse the stored copy instead of calling X:
$post = XPost::query()->where('post_id', $postId)->first();
// Only call X the first time anyone imports this post.
if ($post === null) {
$post = XPost::query()->firstOrCreate(
['post_id' => $postId],
$this->x->fetchPost($postId) + ['fetched_at' => now()],
);
}
firstOrCreate keyed on post_id (with a unique index on that column) keeps two simultaneous imports of the same post from creating duplicates. We also reject a second import of the same post into the same account with a 409.
The trade-off: like and reply counts are a snapshot from import time, not live numbers. For a testimonial wall that's fine, and our privacy page says so.
Step 5: Publish through the normal path
The imported post becomes a regular testimonial, created inside a transaction and published through the same service that handles testimonials collected by form:
return DB::transaction(function () use ($business, $actor, $post) {
$testimonial = new Testimonial([
'x_post_id' => $post->id,
'author_name' => $post->author_name,
'job_title' => '@'.$post->author_username,
'rating' => null, // nobody gave a star rating, so we don't invent one
'body' => $post->text !== '' ? $post->text : '(Post with media only)',
'social_url' => $post->url,
'status' => Testimonial::STATUS_APPROVED,
'source_meta' => ['source' => 'x', 'x_post_id' => $post->post_id],
]);
$testimonial->business_id = $business->id;
$testimonial->save();
$this->audit->record($actor, AuditLog::ACTION_IMPORT, $testimonial);
// Applies plan limits and refreshes any embedded widgets.
return $this->publishing->publish($testimonial, $actor);
});
Reusing the publishing path means we didn't have to re-implement plan limits, audit logging or widget cache refreshes for a new source. The whole feature also sits behind a feature flag, so we could ship it dark and turn it on once it was tested.
Two deliberate choices here:
-
ratingisnull. A post isn't a 5-star review, and showing stars nobody gave would be misleading. -
social_urlalways links back to the original post, so anyone can check the source.
What ends up on the wall
Each card shows the full post text (long posts collapse after five lines with "Show more"), up to four images or video previews in a grid, likes and replies, and the author's name, handle and photo with a link to the original. The wall is a masonry layout that loads 50 posts at a time.
You can see the result here: wallsy.live/w/jev-on-x.
A note on doing this responsibly
Featuring someone's public post is still featuring someone's words. Our rules:
- Only import public posts that are genuinely about your product.
- Never edit the text. Imported posts keep their original wording and link to the source.
- Take a post down if its author asks, or if they delete it.
- In the US, the FTC's rule on consumer reviews and testimonials (Q&A here) bans fake or misleading testimonials, and that applies to posts you choose to feature too.
Try it
The Wall of Love itself is free on Wallsy. Importing posts from X is on the Pro plan. If you want the non-technical walkthrough (setting up the wall, the link, the CTA button and the embed widget), it's on our blog: How to Build a Wall of Love from X Posts.
I'd like to hear from anyone else building on the X API v2: how are you handling note_tweet, media previews and the 200-with-errors responses? Let me know in the comments.
Top comments (1)
Thanks for reading! The live result is here if you want to see how the cards render:
wallsy.live/w/jev-on-x
Questions about the X API side or the Laravel code are welcome. Happy to go deeper on any section.