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/sdkQuick 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.
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
| Parameter | Type | Required | Description |
|---|---|---|---|
config.publisherKey | string | Yes | Your publisher API key. Must start with spk_ and be exactly 34 characters |
config.cacheMostRecentAddress | boolean | No | Enables 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
| Parameter | Type | Required | Description |
|---|---|---|---|
addressOrAddresses | Address | Address[] | undefined | Yes | A single wallet address or array of addresses for the same user. undefined or [] only works when cacheMostRecentAddress is enabled |
options.imageFormat | ImageFormat | Yes | Required image format for the placement. Specify matches the best ad available in that format. See formats |
options.adUnitId | string | No | Identifier 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:
| Format | Aspect Ratio | Resolution | Use case |
|---|---|---|---|
LANDSCAPE | 16:9 | 640×360 | Hero banners, featured placements |
LONG_BANNER | 8.09:1 | 1456×180 | Header/footer placements, leaderboards |
SHORT_BANNER | 16:5 | 640×200 | Inline 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:
| Property | Type | Description |
|---|---|---|
walletAddress | string | The wallet address that matched for this ad |
campaignId | string | Unique identifier for the ad campaign |
adId | string | Unique identifier for this specific ad |
headline | string | Ad headline text (plain text, no markdown) |
content | string | Ad body content. Supports simplified markdown. Max 400 characters |
ctaUrl | string | Call-to-action URL |
ctaLabel | string | Call-to-action button text (plain text, no markdown) |
imageUrl | string | URL to the ad image |
communityName | string | Name of the advertising community (plain text, no markdown) |
communityLogo | string | URL to the community logo |
imageFormat | ImageFormat | The 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:
| Format | Syntax | Example |
|---|---|---|
| Bold | **text** | **Join today** |
| Italic | *text* | *Italic* |
| Underline | __text__ | __Out now__ |
| Bullet points | * item | * Feature 1\n* Feature 2 |
| Line breaks | \n | First 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
0xfollowed 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
| Error | When it’s thrown |
|---|---|
ValidationError | Invalid input — malformed address, bad publisher key format, wrong options |
AuthenticationError | Publisher key is rejected by the API |
NotFoundError | No ad matched this user and format — expected behaviour, handle with fallback content |
APIError | Unexpected 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');
});