Skip to Content
PublishingSDK Reference - v0.4.2

SDK Reference

The Specify JavaScript/TypeScript SDK lets publishers serve personalized ads to users based on their Ethereum or EVM-compatible wallet addresses.

Current version: v0.4.2

Installation

npm install @specify-sh/sdk

Quick start

import Specify, { ImageFormat } from '@specify-sh/sdk'; // Initialize the SDK const specify = new Specify({ publisherKey: 'spk_your_publisher_key_here', // Enable for more efficient client-side serves cacheMostRecentAddress: true }); // Serve an ad to a wallet address try { const ad = await specify.serve('0x742d35Cc6634C0532925a3b8D57C11E4a3e1A510', { imageFormat: ImageFormat.LANDSCAPE }); if (ad) { console.log('Headline:', ad.headline); console.log('Content:', ad.content); console.log('Image URL:', ad.imageUrl); console.log('CTA:', ad.ctaLabel, '->', ad.ctaUrl); } else { console.log('No ad found for this wallet'); } } catch (error) { console.error('Error serving ad:', error); }

Implementing with a coding agent

Using an AI coding agent (Claude Code, Cursor, Copilot, etc.) to wire up Specify? This prompt encodes the non-obvious rules and edge cases up front, so the agent produces a correct integration without having to stop and ask.

You should paste this into your agent’s harness then describe in more detail exactly what your implementation entails, exactly where you want the ads to show up and work.

Coding agent prompt
You are integrating the Specify publisher SDK (@specify-sh/sdk) into this codebase to serve personalized ads based on users' EVM wallet addresses. Follow these rules exactly. MUST - Render a clear "Sponsored" / "Ad" / "Advertisement" label on every placement. The SDK does NOT add it — it's your responsibility and is mandatory. - Treat "no ad" as normal: when serve() returns null or throws NotFoundError (404), render NOTHING — no empty box, no spinner, no error UI. - Pass only EVM addresses the user CONTROLS, sourced from the app's existing wallet/auth layer. If you can't find where the user's addresses live, ASK — do not invent addresses or build a wallet-connection flow. - Render `content` as the simplified-markdown subset it is, never as raw text. MUST NOT - Block, delay, or break the host UI waiting on an ad — fail open on every error. - Modify, wrap, proxy, or add query params to ctaUrl — link to it exactly as returned. - Add any targeting, tracking, or moderation code — all of that is handled server-side or in the Specify dashboard. - Re-call serve() on every render, or render an ad without its label. WHERE TO CALL IT - Call serve() on the CLIENT whenever possible — this is strongly recommended. If a client-side call is genuinely not possible, talk to the Specify team first: geo restrictions are harder to apply to server-side calls, which matters for legal compliance. - publisherKey is a publishable key designed for browser use, so it is safe in client code. Read it from an environment variable; never hard-code it. Keys start with `spk_` and are 34 characters long. SETUP - Install @specify-sh/sdk. Create ONE Specify client at module scope (not per render): new Specify({ publisherKey }) SERVING AN AD - Call: specify.serve(addresses, { imageFormat, adUnitId }) => Promise<SpecifyAd | null> `addresses` may be a single address string or an array of them. - Pass EVERY EVM address you know about for THIS user that the user CONTROLS. More addresses = better matching and more conversions attributed. Do NOT pass watch-only addresses (ones the user added just to view). EVM only (`0x` + 40 hex), case-insensitive; up to 50 per call; duplicates are deduped automatically. - `imageFormat` (REQUIRED): choose the one matching your slot's shape: ImageFormat.LANDSCAPE (16:9, 640x360) hero / featured ImageFormat.LONG_BANNER (8.09:1, 1456x180) header / footer / leaderboard ImageFormat.SHORT_BANNER (16:5, 640x200) inline / sidebar / mobile - `adUnitId` (set it on every call): a stable string labelling this specific placement. It powers per-placement analytics on Specify's backend. Give each A/B variant its own id (e.g. "swap-card-v1", "swap-card-v2"). Keep names consistent across deploys. - Don't refetch on every render: an addresses array's identity changes between renders, so effects/memos must depend on a STABLE key (e.g. the sorted, lower-cased addresses joined) plus imageFormat and adUnitId — not the array reference itself. RENDERING THE RESULT - Specify returns AT MOST ONE ad, and decides which one — there is no list, no ranking, no campaign selection on your side. You just render what comes back. - A SpecifyAd has: walletAddress, campaignId, adId, headline, content, ctaUrl, ctaLabel, imageUrl, communityName, communityLogo, imageFormat. Use as few or as many as your slot needs. (SpecifyAd and the error classes are exported types — use them in TS repos.) - `content` (max 400 chars) is a SIMPLIFIED-MARKDOWN subset: **bold**, *italic*, __underline__, "* " bullet lines, and \n line breaks. Render it properly — printing it raw shows literal asterisks and collapses the line breaks. Every other text field (headline, ctaLabel, communityName) is plain text. - `imageUrl` MAY BE A GIF (animated creative is common and performs well). Render it with a normal <img> — don't assume a static frame. - (Label reminder, see MUST.) The "Sponsored"/"Ad" tag is yours to render, on every ad. - Placement quality is a balance: visible enough to be seen and convert, restrained enough not to annoy the user or harm the brand — a contextual middle ground, not a flashing interstitial and not a hidden 8px footnote. For fixed banner slots, reserve the aspect-ratio box up front to avoid layout shift; inline/native slots can expand naturally. THE NO-AD CASE (IMPORTANT — THIS IS NORMAL) - When Specify has nothing relevant for a user, serve() resolves to `null` OR throws `NotFoundError` (the backend returns a 404). This is EXPECTED, not an error. - On no-ad, render NOTHING (or your own fallback content). Never show an empty box, a stuck spinner, or an error state. - This happens often by design: Specify never serves filler, and on production keys low-activity wallets frequently get no ad because Specify targets users likely to convert. Build the slot to disappear cleanly. ERROR HANDLING (fail open — never block the host UI on an ad) - import { ValidationError, NotFoundError, AuthenticationError, APIError } from the SDK. NotFoundError -> no ad; render nothing (expected). ValidationError -> bad address/key/options; fix input, render nothing, log it. AuthenticationError-> publisher key rejected; log it, render nothing. APIError / other -> transient backend/network issue; render nothing, log it. - Ads usually return within ~200ms and are cached for 24h, but treat serving as best-effort: never make your own page wait on or break because of an ad. WHAT YOU DO NOT NEED TO BUILD (see MUST NOT) - Matching is 100% server-side — no pixels, no profile-building, no targeting setup. The only "targeting control" you have is which users you pass: to EXCLUDE a user from ads, simply don't call serve() for them. - Moderation (allow/block lists, categories, keywords) is configured entirely in the Specify dashboard, never in code. KEYS / TESTING - Get keys from the Specify dashboard. Use a TEST/dev key first: it returns a random, rotating selection of recent ads (so you can QA your layout against many creatives, including GIFs and varying text lengths), and moderation settings do NOT apply to it. - After testing, create a PRODUCTION key in the dashboard and swap it in. Expect more no-ad responses on production than in testing — that is correct behaviour. REFERENCE IMPLEMENTATION (React starting point — adapt to the host framework; the lifecycle is the same everywhere: serve once, then render the ad or render nothing) import { useEffect, useState } from 'react'; import Specify, { ImageFormat, NotFoundError } from '@specify-sh/sdk'; // One client at module scope (not per render). Publishable key from an env var. const specify = new Specify({ publisherKey: process.env.NEXT_PUBLIC_SPECIFY_PUBLISHER_KEY, }); // addresses : EVM addresses the user CONTROLS, from your existing wallet/auth layer // (pass all you know about; don't invent how they're obtained) // imageFormat: an ImageFormat that matches the slot's shape // adUnitId : stable label for this placement (per variant when A/B testing) export function SpecifyPlacement({ addresses, imageFormat, adUnitId }) { const [ad, setAd] = useState(null); // A STABLE primitive key, so we don't refetch when the array's identity changes. const addressKey = [...addresses].map((a) => a.toLowerCase()).sort().join(','); useEffect(() => { let cancelled = false; specify .serve(addresses, { imageFormat, adUnitId }) .then((result) => { if (!cancelled) setAd(result); }) // result is the ad, or null .catch((err) => { // NotFoundError (404) = no ad for this user = expected. Anything else: fail open. if (!(err instanceof NotFoundError)) console.error('Specify serve failed:', err); if (!cancelled) setAd(null); }); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [addressKey, imageFormat, adUnitId]); if (!ad) return null; // no ad / error -> render NOTHING (no empty box, no spinner) return ( <aside className="specify-ad"> {/* REQUIRED disclaimer — your responsibility, the SDK does not add it */} <span className="specify-ad__label">Sponsored</span> <div className="specify-ad__brand"> <img src={ad.communityLogo} alt="" width={20} height={20} /> <span>{ad.communityName}</span> </div> {/* imageUrl may be a GIF — a normal <img> handles it */} <img className="specify-ad__image" src={ad.imageUrl} alt={ad.headline} /> <h3>{ad.headline}</h3> {/* `content` is a simplified-markdown subset (**bold**, *italic*, __underline__, "* " bullets, \n line breaks); parse it if you want the formatting. pre-line at least preserves the line breaks. */} <p className="specify-ad__body" style={{ whiteSpace: 'pre-line' }}>{ad.content}</p> {/* Link to ctaUrl exactly as returned — do not modify or add params */} <a href={ad.ctaUrl} target="_blank" rel="noopener noreferrer sponsored"> {ad.ctaLabel} </a> </aside> ); } Deliverable: a single reusable placement component/function that takes the user's addresses, an imageFormat, and an adUnitId; serves an ad; renders it WITH a "Sponsored" label using the returned fields; and renders nothing on the no-ad/error cases.

Constructor

Creates a new Specify client instance.

const specify = new Specify(config: SpecifyInitConfig);

Parameters

ParameterTypeRequiredDescription
config.publisherKeystringYesYour publisher API key. Must start with spk_ and be exactly 34 characters
config.cacheMostRecentAddressbooleanNoEnables ad serving after a user disconnects their wallet. Defaults to false. Browser-only — has no effect on server-side calls

See Improving Results → Browser caching for when to enable cacheMostRecentAddress.

Example

const specify = new Specify({ publisherKey: 'spk_1234567890abcdef1234567890abcdef', cacheMostRecentAddress: true });

serve()

Serves targeted advertising content for one or more wallet addresses.

serve( addressOrAddresses: Address | Address[] | undefined, options: ServeOptions ): Promise<SpecifyAd | null>

Parameters

ParameterTypeRequiredDescription
addressOrAddressesAddress | Address[] | undefinedYesA single wallet address or array of addresses for the same user. undefined or [] only works when cacheMostRecentAddress is enabled
options.imageFormatImageFormatYesRequired image format for the placement. Specify matches the best ad available in that format. See formats
options.adUnitIdstringNoIdentifier for this specific placement. Used for per-placement performance analytics and A/B testing. Learn more

Returns

Promise<SpecifyAd | null> — the ad content object, or null if no matching ad is available.

Examples

Single wallet address

import { ImageFormat } from '@specify-sh/sdk'; const ad = await specify.serve( '0x742d35Cc6634C0532925a3b8D57C11E4a3e1A510', { imageFormat: ImageFormat.LANDSCAPE } );

With an adUnitId

const ad = await specify.serve( '0x742d35Cc6634C0532925a3b8D57C11E4a3e1A510', { imageFormat: ImageFormat.LANDSCAPE, adUnitId: 'homepage-hero-banner' } );

Multiple wallets for one user

Pass every address you know about for the same user. This significantly improves targeting accuracy and conversion attribution — see why this matters.

const ad = await specify.serve( [ '0x742d35Cc6634C0532925a3b8D57C11E4a3e1A510', '0x8ba1f109551bD432803012645Hac136c0532925a', '0x1234567890123456789012345678901234567890' ], { imageFormat: ImageFormat.LONG_BANNER, adUnitId: 'multi-wallet-banner' } );

No address (browser cache fallback)

When cacheMostRecentAddress is enabled, the SDK can serve an ad using the last known wallet — useful after a user disconnects or their session times out.

const ad = await specify.serve(null, { imageFormat: ImageFormat.LONG_BANNER, adUnitId: 'header-banner' }); // Or with an empty array const ad = await specify.serve([], { imageFormat: ImageFormat.LONG_BANNER, adUnitId: 'header-banner' });

Image format options

The SDK supports three ad image formats to match common layout patterns:

FormatAspect RatioResolutionUse case
LANDSCAPE16:9640×360Hero banners, featured placements
LONG_BANNER8.09:11456×180Header/footer placements, leaderboards
SHORT_BANNER16:5640×200Inline content, sidebars, mobile banners

For guidance on choosing between them — and on building native placements beyond simple banners — see the Placements page.


Return type: SpecifyAd

A successful serve() call returns a SpecifyAd object:

PropertyTypeDescription
walletAddressstringThe wallet address that matched for this ad
campaignIdstringUnique identifier for the ad campaign
adIdstringUnique identifier for this specific ad
headlinestringAd headline text (plain text, no markdown)
contentstringAd body content. Supports simplified markdown. Max 400 characters
ctaUrlstringCall-to-action URL
ctaLabelstringCall-to-action button text (plain text, no markdown)
imageUrlstringURL to the ad image
communityNamestringName of the advertising community (plain text, no markdown)
communityLogostringURL to the community logo
imageFormatImageFormatThe image format returned (LANDSCAPE, LONG_BANNER, or SHORT_BANNER)

The response has more than just an image — headline, content, CTA, and community branding are all available separately, so you can build rich native placements rather than just dropping in a single image.

Content formatting

The content field supports a simplified markdown subset:

FormatSyntaxExample
Bold**text****Join today**
Italic*text**Italic*
Underline__text____Out now__
Bullet points* item* Feature 1\n* Feature 2
Line breaks\nFirst line\nSecond line

Validation rules

Publisher key

  • Must start with spk_
  • Must be exactly 34 characters long
  • Example: spk_1234567890abcdef1234567890abcdef

Wallet addresses

  • Must be valid Ethereum / EVM-compatible addresses
  • Must start with 0x followed by 40 hexadecimal characters
  • Case-insensitive
  • Example: 0x742d35Cc6634C0532925a3b8D57C11E4a3e1A510

Address limits per serve() call

  • Minimum: 1 address (or null / [] with caching enabled)
  • Maximum: 50 addresses per request
  • Duplicates: automatically deduplicated

Error handling

Wrap SDK calls in try/catch and branch on the specific error classes exported by the SDK.

import { AuthenticationError, ValidationError, NotFoundError, APIError, ImageFormat } from '@specify-sh/sdk'; try { const ad = await specify.serve(walletAddress, { imageFormat: ImageFormat.LANDSCAPE, adUnitId: 'main-content-ad' }); if (ad) { displayAd(ad); } else { showFallbackContent(); } } catch (error) { if (error instanceof ValidationError) { console.error('Invalid input:', error.message); } else if (error instanceof NotFoundError) { showFallbackContent(); } else if (error instanceof AuthenticationError) { console.error('Authentication failed. Check your publisher key.'); } else if (error instanceof APIError) { console.error('API error:', error.message); showErrorMessage(); } else { console.error('Unexpected error:', error); } }

Error reference

ErrorWhen it’s thrown
ValidationErrorInvalid input — malformed address, bad publisher key format, wrong options
AuthenticationErrorPublisher key is rejected by the API
NotFoundErrorNo ad matched this user and format — expected behaviour, handle with fallback content
APIErrorUnexpected API-side error. Retry or show a fallback

NotFoundError is a normal outcome, not a bug — it just means Specify had nothing relevant to serve to this user right now. Plan your UI around this case rather than treating it as failure.


Integration examples

React component

import React, { useState, useEffect } from 'react'; import Specify, { SpecifyAd, ValidationError, NotFoundError, ImageFormat } from '@specify-sh/sdk'; const specify = new Specify({ publisherKey: process.env.REACT_APP_SPECIFY_PUBLISHER_KEY!, cacheMostRecentAddress: true }); interface AdComponentProps { walletAddress: string; imageFormat?: ImageFormat; adUnitId?: string; } const AdComponent: React.FC<AdComponentProps> = ({ walletAddress, imageFormat = ImageFormat.LANDSCAPE, adUnitId }) => { const [ad, setAd] = useState<SpecifyAd | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); useEffect(() => { const fetchAd = async () => { try { setLoading(true); setError(null); const adData = await specify.serve(walletAddress, { imageFormat, adUnitId }); setAd(adData); } catch (err) { if (err instanceof ValidationError) { setError('Invalid wallet address'); } else if (err instanceof NotFoundError) { setAd(null); } else { setError('Failed to load ad'); } } finally { setLoading(false); } }; if (walletAddress) fetchAd(); }, [walletAddress, imageFormat, adUnitId]); if (loading) return <div>Loading ad...</div>; if (error) return <div>Error: {error}</div>; if (!ad) return null; // No ad — render nothing (or your own fallback) return ( <div className="ad-container"> <div className="ad-header"> <img src={ad.communityLogo} alt={ad.communityName} className="community-logo" /> <span className="community-name">{ad.communityName}</span> </div> <h3 className="ad-headline">{ad.headline}</h3> <img src={ad.imageUrl} alt={ad.headline} className="ad-image" /> <p className="ad-content">{ad.content}</p> <a href={ad.ctaUrl} className="ad-cta" target="_blank" rel="noopener noreferrer"> {ad.ctaLabel} </a> </div> ); }; export default AdComponent;

Node.js server

import express from 'express'; import Specify, { ValidationError, NotFoundError, ImageFormat } from '@specify-sh/sdk'; const app = express(); const specify = new Specify({ publisherKey: process.env.SPECIFY_PUBLISHER_KEY! // Note: cacheMostRecentAddress has no effect server-side }); app.get('/api/ads/:walletAddress', async (req, res) => { try { const { walletAddress } = req.params; const { format = 'LANDSCAPE', adUnitId } = req.query; const ad = await specify.serve(walletAddress, { imageFormat: format as ImageFormat, adUnitId: adUnitId as string | undefined }); if (ad) { res.json({ success: true, ad: { ...ad, timestamp: new Date().toISOString() } }); } else { res.status(404).json({ success: false, message: 'No ad found' }); } } catch (error) { if (error instanceof ValidationError) { res.status(400).json({ success: false, message: error.message, details: (error as ValidationError & { details?: unknown }).details }); } else if (error instanceof NotFoundError) { res.status(404).json({ success: false, message: 'No ad found' }); } else { console.error('Server error:', error); res.status(500).json({ success: false, message: 'Internal server error' }); } } }); app.listen(3000, () => { console.log('Server running on port 3000'); });
Last updated on