skills/poplar-direct-mail/SKILL.md
Design and send programmatic direct mail using Poplar's HTML templates and API. Use for creating dynamic mail pieces (postcards, bifolds, trifolds, letters) with personalization and triggering mail via API.
npx skillsauth add ckorhonen/claude-skills poplar-direct-mailInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Design professional direct mail creative using Poplar's HTML template system and send programmatic mail pieces via their API. This skill covers template creation with dynamic personalization, API integration for triggered mailings, and creative best practices for high-converting direct mail campaigns.
requests packagepip install requests
Set your API token:
# Test token (for development - only works with mailing endpoint)
export POPLAR_API_TOKEN="test_your_token_here"
# Production token (for live mailings)
export POPLAR_API_TOKEN="your_production_token_here"
| Size | Final Trim | With Bleed | Pixels (300 PPI) | |------|------------|------------|------------------| | 4" x 6" | 4" x 6" | 4.25" x 6.25" | 1275 x 1875 | | 6" x 9" | 6" x 9" | 6.25" x 9.25" | 1875 x 2775 | | 6" x 11" | 6" x 11" | 6.25" x 11.25" | 1875 x 3375 |
Best for: Retargeting, time-sensitive promotions, simple messaging, direct mail newcomers
| Format | Pixels (300 PPI) | |--------|------------------| | Short-fold (folds left to right) | 1725 x 5175 | | Long-fold (folds top to bottom) | 3375 x 2625 |
Best for: Prospecting campaigns, new product launches, complex value propositions
| Format | Pixels (300 PPI) | |--------|------------------| | Standard Tri-fold | 2625 x 4987 |
Best for: Multiple product selections, detailed information, catalogs
| Format | Dimensions | |--------|------------| | 8.5" x 11" (Color or B&W) | 612 x 792 px (no bleed required) |
Best for: Financial/insurance, sensitive content, transactional mailings, professional communications
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&display=swap"
rel="stylesheet" type="text/css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 4.25in;
height: 6.25in;
position: relative;
font-family: 'Open Sans', sans-serif;
}
.background {
position: absolute;
width: 100%;
height: 100%;
background-image: url('https://your-hosted-image.com/background.png');
background-size: cover;
}
.headline {
position: absolute;
top: 0.5in;
left: 0.375in;
font-size: 28pt;
font-weight: 700;
color: #1a1a1a;
}
.personalized-text {
position: absolute;
top: 1.2in;
left: 0.375in;
font-size: 14pt;
color: #333333;
}
.offer-box {
position: absolute;
bottom: 1in;
left: 0.375in;
width: 3.5in;
padding: 0.25in;
background-color: #ff6b35;
color: white;
text-align: center;
font-size: 18pt;
font-weight: 600;
}
</style>
</head>
<body>
<div class="background"></div>
<div class="headline">Special Offer Inside!</div>
<div class="personalized-text">
Hi {{recipient.first_name | default: "Friend"}},
</div>
<div class="offer-box">
Use code: {{promotion.promo_code}}
</div>
</body>
</html>
| Requirement | Details |
|-------------|---------|
| CSS Location | All styles in <style> tag within <head> |
| Positioning | Use position: absolute for all elements |
| Units | Only in (inches) or px (pixels) - no em, rem, vw, vh |
| Image URLs | Absolute URLs only (must be publicly accessible) |
| Fonts | Google Fonts or self-hosted .ttf/.woff files |
Google Fonts (recommended):
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700&display=swap"
rel="stylesheet" type="text/css">
Custom fonts:
@font-face {
font-family: "CustomFont";
src: url("https://your-domain.com/fonts/CustomFont.ttf") format("truetype");
}
{{recipient.first_name}} <!-- First name or "Current Resident" -->
{{recipient.last_name}} <!-- Last name -->
{{recipient.full_name}} <!-- Full name -->
{{recipient.address_1}} <!-- Street address -->
{{recipient.city}} <!-- City -->
{{recipient.state}} <!-- State -->
{{recipient.postal_code}} <!-- ZIP code -->
{{promotion.promo_code}} <!-- Unique promo code -->
{{promotion.qr_url}} <!-- URL for QR code generation -->
{{location.city}} <!-- Recipient's city -->
{{location.state}} <!-- Recipient's state -->
{{location.store_address}} <!-- Nearest store address -->
{{custom.purchase_amount}} <!-- Custom field from your data -->
{{custom.product_name}} <!-- Product purchased -->
{{custom.loyalty_tier}} <!-- Customer tier -->
{% if recipient.first_name %}
Hi {{recipient.first_name | capitalize}},
{% else %}
Dear Valued Customer,
{% endif %}
{% if custom.lifetime_value >= 500 %}
<div class="offer">Enjoy 25% off your next order!</div>
{% elsif custom.lifetime_value >= 200 %}
<div class="offer">Take 15% off your next purchase!</div>
{% else %}
<div class="offer">Get $10 off orders over $50!</div>
{% endif %}
<!-- 30 days from print date -->
Expires: {{ "now" | date: "%s" | plus: 2592000 | date: "%B %e, %Y" }}
<!-- 90 days from print date -->
Expires: {{ "now" | date: "%s" | plus: 7776000 | date: "%B %e, %Y" }}
{{ recipient.first_name | capitalize }} <!-- Capitalizes first letter -->
{{ recipient.first_name | upcase }} <!-- ALL CAPS -->
{{ recipient.first_name | downcase }} <!-- all lowercase -->
{{ custom.price | money }} <!-- Format as currency -->
All requests require a Bearer token:
curl -H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
https://api.heypoplar.com/v1/me
Endpoint: POST https://api.heypoplar.com/v1/mailing
curl -X POST https://api.heypoplar.com/v1/mailing \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"campaign_id": "your-campaign-id",
"creative_id": "your-creative-id",
"recipient": {
"first_name": "Jane",
"last_name": "Smith",
"address_1": "123 Main Street",
"address_2": "Apt 4B",
"city": "San Francisco",
"state": "CA",
"postal_code": "94102"
},
"merge_tags": {
"promo_code": "SAVE20",
"expiration_date": "December 31, 2024"
}
}'
Response (201 Created):
{
"id": "mailing-uuid",
"campaign_id": "campaign-uuid",
"creative_id": "creative-uuid",
"state": "processing",
"front_url": "https://app.heypoplar.com/preview/front.png",
"back_url": "https://app.heypoplar.com/preview/back.png",
"pdf_url": "https://app.heypoplar.com/preview/proof.pdf",
"total_cost": "0.89",
"created_at": "2024-01-15T10:30:00Z"
}
curl -X POST https://api.heypoplar.com/v1/mailing \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"campaign_id": "your-campaign-id",
"recipient": {
"full_name": "John Doe",
"address_1": "456 Oak Avenue",
"city": "Austin",
"state": "TX",
"postal_code": "78701"
},
"send_at": "2024-02-01T09:00:00Z"
}'
Endpoint: GET https://api.heypoplar.com/v1/mailing/:id
curl https://api.heypoplar.com/v1/mailing/MAILING_ID \
-H "Authorization: Bearer YOUR_TOKEN"
Endpoint: GET https://api.heypoplar.com/v1/campaigns
curl https://api.heypoplar.com/v1/campaigns \
-H "Authorization: Bearer YOUR_TOKEN"
Endpoint: GET https://api.heypoplar.com/v1/campaign/:id/creatives
curl https://api.heypoplar.com/v1/campaign/CAMPAIGN_ID/creatives \
-H "Authorization: Bearer YOUR_TOKEN"
Endpoint: POST https://api.heypoplar.com/v1/do-not-mail
curl -X POST https://api.heypoplar.com/v1/do-not-mail \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"address": {
"address_1": "123 Main St",
"postal_code": "94102"
}
}'
#!/usr/bin/env python3
"""Send a single direct mail piece via Poplar API."""
import os
import requests
import argparse
POPLAR_API_URL = "https://api.heypoplar.com/v1"
def send_mailing(
campaign_id: str,
recipient: dict,
creative_id: str = None,
merge_tags: dict = None,
send_at: str = None
) -> dict:
"""Send a mailing via Poplar API."""
token = os.environ.get("POPLAR_API_TOKEN")
if not token:
raise ValueError("POPLAR_API_TOKEN environment variable not set")
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
payload = {
"campaign_id": campaign_id,
"recipient": recipient
}
if creative_id:
payload["creative_id"] = creative_id
if merge_tags:
payload["merge_tags"] = merge_tags
if send_at:
payload["send_at"] = send_at
response = requests.post(
f"{POPLAR_API_URL}/mailing",
headers=headers,
json=payload
)
response.raise_for_status()
return response.json()
def main():
parser = argparse.ArgumentParser(description="Send a Poplar direct mail piece")
parser.add_argument("--campaign-id", required=True, help="Campaign ID")
parser.add_argument("--creative-id", help="Creative ID (optional)")
parser.add_argument("--first-name", required=True, help="Recipient first name")
parser.add_argument("--last-name", required=True, help="Recipient last name")
parser.add_argument("--address", required=True, help="Street address")
parser.add_argument("--city", required=True, help="City")
parser.add_argument("--state", required=True, help="State (2-letter code)")
parser.add_argument("--zip", required=True, help="ZIP code")
parser.add_argument("--promo-code", help="Promo code merge tag")
args = parser.parse_args()
recipient = {
"first_name": args.first_name,
"last_name": args.last_name,
"address_1": args.address,
"city": args.city,
"state": args.state,
"postal_code": args.zip
}
merge_tags = {}
if args.promo_code:
merge_tags["promo_code"] = args.promo_code
result = send_mailing(
campaign_id=args.campaign_id,
recipient=recipient,
creative_id=args.creative_id,
merge_tags=merge_tags if merge_tags else None
)
print(f"Mailing created: {result['id']}")
print(f"Status: {result['state']}")
print(f"Cost: ${result['total_cost']}")
print(f"PDF Preview: {result['pdf_url']}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Send batch mailings from a CSV file."""
import os
import csv
import time
import requests
import argparse
from typing import Generator
POPLAR_API_URL = "https://api.heypoplar.com/v1"
def read_recipients(csv_path: str) -> Generator[dict, None, None]:
"""Read recipients from CSV file."""
with open(csv_path, 'r') as f:
reader = csv.DictReader(f)
for row in reader:
yield {
"recipient": {
"first_name": row.get("first_name", ""),
"last_name": row.get("last_name", ""),
"address_1": row.get("address_1", ""),
"address_2": row.get("address_2", ""),
"city": row.get("city", ""),
"state": row.get("state", ""),
"postal_code": row.get("postal_code", "")
},
"merge_tags": {
k: v for k, v in row.items()
if k not in ["first_name", "last_name", "address_1",
"address_2", "city", "state", "postal_code"]
}
}
def send_batch(
csv_path: str,
campaign_id: str,
creative_id: str = None,
delay: float = 0.1
) -> tuple[int, int]:
"""Send batch mailings from CSV."""
token = os.environ.get("POPLAR_API_TOKEN")
if not token:
raise ValueError("POPLAR_API_TOKEN environment variable not set")
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
success_count = 0
error_count = 0
for record in read_recipients(csv_path):
payload = {
"campaign_id": campaign_id,
"recipient": record["recipient"]
}
if creative_id:
payload["creative_id"] = creative_id
if record["merge_tags"]:
payload["merge_tags"] = record["merge_tags"]
try:
response = requests.post(
f"{POPLAR_API_URL}/mailing",
headers=headers,
json=payload
)
response.raise_for_status()
success_count += 1
print(f"Sent to {record['recipient']['first_name']} {record['recipient']['last_name']}")
except requests.exceptions.RequestException as e:
error_count += 1
print(f"Error sending to {record['recipient']}: {e}")
time.sleep(delay)
return success_count, error_count
def main():
parser = argparse.ArgumentParser(description="Send batch Poplar mailings from CSV")
parser.add_argument("--csv", required=True, help="Path to CSV file")
parser.add_argument("--campaign-id", required=True, help="Campaign ID")
parser.add_argument("--creative-id", help="Creative ID (optional)")
parser.add_argument("--delay", type=float, default=0.1, help="Delay between requests (seconds)")
args = parser.parse_args()
success, errors = send_batch(
csv_path=args.csv,
campaign_id=args.campaign_id,
creative_id=args.creative_id,
delay=args.delay
)
print(f"\nCompleted: {success} sent, {errors} errors")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Test Poplar API connection and list campaigns."""
import os
import requests
POPLAR_API_URL = "https://api.heypoplar.com/v1"
def test_connection():
"""Test API connection and list available campaigns."""
token = os.environ.get("POPLAR_API_TOKEN")
if not token:
print("Error: POPLAR_API_TOKEN environment variable not set")
return False
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json"
}
# Test authentication
print("Testing API connection...")
try:
response = requests.get(f"{POPLAR_API_URL}/me", headers=headers)
response.raise_for_status()
org = response.json()
print(f"Connected to organization: {org.get('name', 'Unknown')}")
except requests.exceptions.RequestException as e:
print(f"Authentication failed: {e}")
return False
# List campaigns
print("\nAvailable campaigns:")
try:
response = requests.get(f"{POPLAR_API_URL}/campaigns", headers=headers)
response.raise_for_status()
campaigns = response.json()
if not campaigns:
print(" No campaigns found")
else:
for campaign in campaigns:
print(f" - {campaign.get('name', 'Unnamed')}: {campaign.get('id')}")
except requests.exceptions.RequestException as e:
print(f"Failed to list campaigns: {e}")
return False
return True
if __name__ == "__main__":
test_connection()
QR code images must be pre-generated and provided as a URL in your merge tags. Poplar does not auto-generate QR codes.
Providing QR codes:
merge_tags = {
"promotion": {
"qr_url": "https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=https://yoursite.com/offer123"
}
}
Or use services like:
Design guidelines:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;800&display=swap" rel="stylesheet">
<style>
body {
width: 4.25in;
height: 6.25in;
position: relative;
font-family: 'Poppins', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 0;
}
.safe-zone {
position: absolute;
top: 0.25in;
left: 0.25in;
right: 0.25in;
bottom: 0.25in;
}
.logo {
position: absolute;
top: 0;
left: 0;
width: 1.5in;
}
.headline {
position: absolute;
top: 1in;
left: 0;
right: 0;
font-size: 32pt;
font-weight: 800;
color: white;
text-align: center;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.subhead {
position: absolute;
top: 1.8in;
left: 0;
right: 0;
font-size: 16pt;
color: rgba(255,255,255,0.9);
text-align: center;
}
.offer-badge {
position: absolute;
top: 2.5in;
left: 50%;
transform: translateX(-50%);
width: 2.5in;
height: 2.5in;
background: white;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
.offer-amount {
font-size: 48pt;
font-weight: 800;
color: #764ba2;
line-height: 1;
}
.offer-text {
font-size: 14pt;
color: #333;
text-transform: uppercase;
letter-spacing: 2px;
}
</style>
</head>
<body>
<div class="safe-zone">
<img src="https://your-domain.com/logo-white.svg" class="logo" alt="Logo">
<div class="headline">{{recipient.first_name | capitalize}}, You're Invited!</div>
<div class="subhead">Exclusive offer just for you</div>
<div class="offer-badge">
<div class="offer-amount">25%</div>
<div class="offer-text">Off Everything</div>
</div>
</div>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600&display=swap" rel="stylesheet">
<style>
body {
width: 4.25in;
height: 6.25in;
position: relative;
font-family: 'Poppins', sans-serif;
background: white;
margin: 0;
}
.content-area {
position: absolute;
top: 0.25in;
left: 0.25in;
width: 3.25in;
bottom: 0.25in;
}
.details {
position: absolute;
top: 0;
left: 0;
font-size: 11pt;
color: #333;
line-height: 1.6;
}
.promo-code-box {
position: absolute;
top: 1.5in;
left: 0;
background: #f8f8f8;
border: 2px dashed #764ba2;
padding: 0.15in 0.25in;
text-align: center;
}
.promo-label {
font-size: 10pt;
color: #666;
text-transform: uppercase;
}
.promo-code {
font-size: 18pt;
font-weight: 600;
color: #764ba2;
letter-spacing: 3px;
}
.expiry {
position: absolute;
top: 2.4in;
left: 0;
font-size: 9pt;
color: #999;
}
.qr-section {
position: absolute;
bottom: 0;
left: 0;
}
.qr-code {
width: 1in;
height: 1in;
}
.qr-text {
font-size: 9pt;
color: #666;
margin-top: 0.1in;
}
/* Address block area - leave clear for Poplar */
.address-area {
position: absolute;
top: 0.375in;
right: 0.25in;
width: 2.625in;
height: 1.125in;
/* This area will be covered by Poplar's address block */
}
</style>
</head>
<body>
<div class="content-area">
<div class="details">
Shop our entire collection with your<br>
exclusive discount. Free shipping on<br>
orders over $50!
</div>
<div class="promo-code-box">
<div class="promo-label">Your Code</div>
<div class="promo-code">{{promotion.promo_code}}</div>
</div>
<div class="expiry">
Expires: {{ "now" | date: "%s" | plus: 2592000 | date: "%B %e, %Y" }}
</div>
<div class="qr-section">
<img src="{{promotion.qr_url}}" class="qr-code" alt="Scan to shop">
<div class="qr-text">Scan to shop now</div>
</div>
</div>
</body>
</html>
"Unsupported unit" error
in or px units, not em, rem, vw, vh, or %Images not appearing
https://)Fonts not rendering
<link> tagMerge tags not replaced
| default: "fallback" for optional fields401 Unauthorized
/mailing)400 Bad Request
address_1, city, state, postal_codeCampaign not active error
Browser vs PDF preview differ
documentation
Create or expand an Idea.md / IDEA.md file from a rough description, existing repo, conversation history, notes, or other early-stage product inputs. Use when the user asks to "write an Idea.md", "turn this into an idea file", "capture this product idea", "expand this concept", or wants a repo-grounded concept brief before validation, PRD, or implementation work.
development
Write structured implementation plans from specs or requirements before touching code. Use when given a spec, requirements doc, or feature description, when user says "plan this out", "write a plan for", "how should we implement", or before starting any multi-step coding task.
testing
Expert guidance for video editing with ffmpeg, encoding best practices, and quality optimization. Use when working with video files, transcoding, remuxing, encoding settings, color spaces, or troubleshooting video quality issues.
development
Opinionated constraints for building better interfaces with agents. Use when building UI components, implementing animations, designing layouts, reviewing frontend accessibility, or working with Tailwind CSS, motion/react, or accessible primitives like Radix/Base UI.