Skip to content

Battle Share-Card API

The share-card endpoint renders a 1200×630 social-card SVG for a single battle. It is the surface BattleSEOHead points og:image at, so every public battle URL gets a crawler-ready preview without any client-side rendering.

The endpoint is implemented by apps/platform-api/src/http/routes/battles-share-card.route.ts and is mounted on the platform API.


Endpoint

GET /v1/battles/:slug/share-card.svg
AspectValue
AuthNone — public surface
Path paramslug — the battle slug (matches battles.battles.slug)
MethodGET
VisibilityPublic for battles whose status is not draft and deleted_at IS NULL. Drafts and soft-deleted battles return 404.

Responses

200 OK

Content-Type: image/svg+xml; charset=utf-8
Cache-Control: public, max-age=300, s-maxage=600

Body is a complete SVG document (<?xml version="1.0" ?><svg ...>). The card is fixed at 1200×630 to match the standard Open Graph / Twitter image aspect ratio. Browsers, Slack, Discord, Twitter/X, LinkedIn, and Facebook all rasterize SVG correctly when fetching og:image.

404 Not Found

json
{ "error": "not_found", "message": "Battle share card not found." }

Returned for an unknown slug, a soft-deleted battle (deleted_at IS NOT NULL), or a battle still in draft.

500 Internal Server Error

json
{ "error": "share_card_failed", "message": "<reason>" }

Returned when the underlying Supabase query throws. The route deliberately surfaces the error message string for debugging; nothing in the data path is user-controlled at this point.


Card content

The SVG renders a single composite layout. The fields below are pulled from battles.battles, battles.contenders, and reputation.elo_battle_log:

ElementSource
Header badgeConstant LenserFight wordmark
Status badge (top right)Uppercased status, or the literal FINALIZED once finalized_at IS NOT NULL
Titlebattles.title, truncated to 64 characters
Contender A namecontenders.display_name for slot='A', truncated to 24 characters
Contender B namecontenders.display_name for slot='B', truncated to 24 characters
Winner highlightThe winning contender's name renders in the highlight color when winner_contender_id is set
ELO delta linesreputation.elo_battle_log deltas. Rendered only when the battle is finalized. Sign and rounded integer (e.g. +18 ELO, -12 ELO).
Vote linetotal_vote_count votes — only when the battle is finalized
Footerlenserfight.com/battles/<slug>

All user-controlled strings are escaped for safe inclusion as SVG text content (&, <, >, ", ').


Embedding

BattleSEOHead already wires the URL into Open Graph and Twitter card meta tags:

html
<meta property="og:image" content="https://api.lenserfight.com/v1/battles/csv-parser-2026/share-card.svg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://api.lenserfight.com/v1/battles/csv-parser-2026/share-card.svg" />

Consumers should not need to call this endpoint directly; visiting any battle detail URL surfaces the card automatically.


Caching

Responses set Cache-Control: public, max-age=300, s-maxage=600. Five minutes browser, ten minutes CDN. The endpoint has no per-request user state, so it caches cleanly behind any reverse proxy.

There is no on-write cache invalidation today. A battle.finalized event-driven pre-warm is tracked as a follow-up (see "Future" below).


Future

The route file carries two TODOs:

  • Pre-warm cache on battle.finalized — consume Phase U1 events to invalidate and re-fetch the card the moment a battle finalizes. Today the on-demand cache headers are the only freshness mechanism.
  • PNG upgrade — switch from raw SVG to @vercel/og (or satori + resvg) for native PNG output. SVG is universally rasterized by social crawlers, so SVG is a safe MVP. The PNG migration is a bundle-size / dependency decision, not a correctness one. Tracked in Known Limitations → Battles.