service-integration
Resources
scripts/
validate-services.sh
references/
service-patterns.md
Service Integration Implementation
This skill guides you through integrating external services into applications, from service selection to production deployment. It leverages GoodVibes precision tools for type-safe, resilient service integrations with proper error handling and testing.
When to Use This Skill
Use this skill when you need to:
- Integrate email providers (Resend, SendGrid, Postmark)
- Set up content management systems (Sanity, Contentful, Payload, Strapi)
- Implement file upload services (UploadThing, Cloudinary, S3)
- Add analytics and tracking (PostHog, Plausible, Google Analytics)
- Configure webhook endpoints for third-party services
- Implement retry logic and circuit breakers
- Test service integrations without hitting production APIs
Prerequisites
Required context:
- Service requirements (email, CMS, uploads, analytics)
- Expected traffic volume and scale requirements
- Budget constraints
- Self-hosted vs managed service preference
Tools:
- precision_grep for detecting existing integrations
- precision_read for analyzing service configurations
- precision_write for creating integration code
- precision_exec for testing and validation
- discover for batch analysis
Phase 1: Discovery - Detect Existing Integrations
Before adding new services, analyze existing integrations to maintain consistency.
Step 1.1: Detect Service SDKs and Patterns
Use discover to analyze multiple aspects in parallel:
discover:
queries:
- id: email-sdks
type: grep
pattern: "(resend|sendgrid|postmark|nodemailer)"
glob: "package.json"
- id: cms-sdks
type: grep
pattern: "(@sanity|contentful|@payloadcms|@strapi)"
glob: "package.json"
- id: upload-sdks
type: grep
pattern: "(uploadthing|cloudinary|@aws-sdk/client-s3)"
glob: "package.json"
- id: analytics-sdks
type: grep
pattern: "(posthog-js|plausible|@vercel/analytics)"
glob: "package.json"
- id: api-keys-env
type: grep
pattern: "(RESEND_|SENDGRID_|SANITY_|CONTENTFUL_|UPLOADTHING_|CLOUDINARY_|AWS_|POSTHOG_)"
glob: ".env.example"
- id: service-clients
type: glob
patterns: ["src/lib/*client.ts", "src/services/**/*.ts", "lib/services/**/*.ts"]
- id: webhook-routes
type: glob
patterns: ["src/app/api/webhooks/**/*.ts", "pages/api/webhooks/**/*.ts"]
verbosity: files_only
Step 1.2: Analyze Service Client Patterns
Read existing service clients to understand the project's patterns:
precision_read:
files:
- path: "src/lib/email-client.ts"
extract: outline
- path: "src/services/upload.ts"
extract: symbols
output:
format: minimal
Decision Point: Use existing patterns for new integrations. If no patterns exist, follow the implementation guide below.
Phase 2: Service Selection
Choose services based on requirements, scale, and budget.
Email Services Decision Tree
For transactional emails:
- Resend - Best DX, React Email support, 100 emails/day free, $20/month for 50K
- SendGrid - Enterprise features, 100 emails/day free, $20/month for 100K
- Postmark - High deliverability focus, $15/month for 10K, no free tier
- AWS SES - Cheapest at scale ($0.10/1000), requires more setup
For marketing emails:
- ConvertKit - Creator-focused, $29/month for 1K subscribers
- Mailchimp - All-in-one platform, free for 500 subscribers
- Loops - Developer-friendly, $49/month for 2K subscribers
Recommendation: Start with Resend for transactional, migrate to SES at 1M+ emails/month.
CMS Platform Decision Tree
For structured content (blog, docs):
- Sanity - Best DX, real-time collaboration, free tier generous
- Contentful - Enterprise-ready, robust GraphQL, complex pricing
- Payload - Self-hosted, full TypeScript, no vendor lock-in
For app content (products, user-generated):
- Payload - Best for complex data models, authentication built-in
- Strapi - Large plugin ecosystem, self-hosted
For marketing pages:
- Builder.io - Visual editor, A/B testing built-in
- Sanity - Developer-friendly, visual editing with Sanity Studio
Recommendation: Sanity for content-heavy sites, Payload for app backends.
File Upload Decision Tree
For images (profile pics, product photos):
- UploadThing - Zero config, Next.js integration, $10/month for 2GB storage
- Cloudinary - Image transformations, free tier 25GB bandwidth
- Vercel Blob - Edge network, $0.15/GB storage
For large files (videos, documents):
- AWS S3 - Industry standard, $0.023/GB storage, cheapest at scale
- Cloudflare R2 - S3-compatible, zero egress fees, $0.015/GB storage
- Backblaze B2 - Cheapest storage at $0.005/GB
For user-facing uploads with virus scanning:
- UploadThing - Built-in virus scanning
- AWS S3 + Lambda - DIY scanning with ClamAV
Recommendation: UploadThing for prototypes, S3/R2 for production scale.
Analytics Decision Tree
For product analytics:
- PostHog - Self-hosted option, session replay, feature flags, free tier 1M events
- Mixpanel - User-centric analytics, free tier 20M events/month
- Amplitude - Advanced cohort analysis, free tier 10M events/month
For web analytics:
- Plausible - Privacy-focused, GDPR-compliant, $9/month for 10K pageviews
- Umami - Self-hosted, simple, open source
- Vercel Analytics - Web Vitals focus, free for Vercel projects
Recommendation: PostHog for product apps, Plausible for marketing sites.
Phase 3: Email Integration
Step 3.1: Install Resend SDK
precision_exec:
commands:
- cmd: "npm install resend"
expect:
exit_code: 0
verbosity: minimal
Step 3.2: Create Email Client
Write a type-safe email client with error handling:
precision_write:
files:
- path: "src/lib/email.ts"
mode: fail_if_exists
content: |
import type { ReactElement } from 'react';
import { Resend } from 'resend';
if (!process.env.RESEND_API_KEY) {
throw new Error('RESEND_API_KEY is required');
}
export const resend = new Resend(process.env.RESEND_API_KEY);
export interface SendEmailOptions {
to: string | string[];
subject: string;
html?: string;
react?: ReactElement;
from?: string;
}
export async function sendEmail(options: SendEmailOptions) {
const { to, subject, html, react, from = 'noreply@yourapp.com' } = options;
try {
const { data, error } = await resend.emails.send({
from,
to,
subject,
html,
react,
});
if (error) {
console.error('[Email] Send failed:', error);
throw new Error(`Email send failed: ${error.message}`);
}
console.log('[Email] Sent successfully:', data?.id);
return { success: true, id: data?.id };
} catch (error: unknown) {
console.error('[Email] Unexpected error:', error);
throw error;
}
}
verbosity: minimal
Step 3.3: Create React Email Templates
For transactional emails, use React Email for type-safe templates:
precision_exec:
commands:
- cmd: "npm install react-email @react-email/components"
expect:
exit_code: 0
verbosity: minimal
precision_write:
files:
- path: "emails/welcome.tsx"
mode: fail_if_exists
content: |
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Preview,
Text,
} from '@react-email/components';
interface WelcomeEmailProps {
userName: string;
loginUrl: string;
}
export default function WelcomeEmail({ userName, loginUrl }: WelcomeEmailProps) {
return (
<Html>
<Head />
<Preview>Welcome to our platform!</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Welcome, {userName}!</Heading>
<Text style={text}>
We're excited to have you on board. Click the button below to get started.
</Text>
<Button href={loginUrl} style={button}>
Get Started
</Button>
</Container>
</Body>
</Html>
);
}
const main = { backgroundColor: '#f6f9fc', fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif' };
const container = { margin: '0 auto', padding: '40px 20px' };
const h1 = { color: '#1f2937', fontSize: '24px', fontWeight: 'bold', marginBottom: '20px' };
const text = { color: '#4b5563', fontSize: '16px', lineHeight: '24px', marginBottom: '20px' };
const button = { backgroundColor: '#3b82f6', borderRadius: '6px', color: '#ffffff', display: 'inline-block', fontSize: '16px', fontWeight: '600', padding: '12px 24px', textDecoration: 'none' };
verbosity: minimal
Step 3.4: Document Environment Variables
precision_write:
files:
- path: ".env.example"
mode: overwrite
content: |
# Email (Resend)
RESEND_API_KEY=your_resend_api_key_here
verbosity: minimal
Phase 4: CMS Integration
Step 4.1: Install Sanity SDK
precision_exec:
commands:
- cmd: "npm install @sanity/client @sanity/image-url"
expect:
exit_code: 0
verbosity: minimal
Step 4.2: Create Sanity Client
precision_write:
files:
- path: "src/lib/sanity.ts"
mode: fail_if_exists
content: |
import { createClient } from '@sanity/client';
import imageUrlBuilder from '@sanity/image-url';
import type { SanityImageSource } from '@sanity/image-url/lib/types/types';
if (!process.env.NEXT_PUBLIC_SANITY_PROJECT_ID) {
throw new Error('NEXT_PUBLIC_SANITY_PROJECT_ID is required');
}
if (!process.env.NEXT_PUBLIC_SANITY_DATASET) {
throw new Error('NEXT_PUBLIC_SANITY_DATASET is required');
}
export const sanityClient = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET,
apiVersion: '2026-01-01', // Update to current API version date
useCdn: process.env.NODE_ENV === 'production',
});
const builder = imageUrlBuilder(sanityClient);
// Import from @sanity/image-url
export function urlForImage(source: SanityImageSource) {
return builder.image(source).auto('format').fit('max');
}
// Type-safe query helper
export async function sanityFetch<T>(query: string, params?: Record<string, unknown>): Promise<T> {
try {
const result = await sanityClient.fetch<T>(query, params);
return result;
} catch (error: unknown) {
console.error('[Sanity] Query failed:', error);
throw new Error('Failed to fetch from Sanity');
}
}
verbosity: minimal
Step 4.3: Set Up Webhook Endpoint
For real-time content updates, implement a webhook handler:
precision_write:
files:
- path: "src/app/api/webhooks/sanity/route.ts"
mode: fail_if_exists
content: |
import { NextRequest, NextResponse } from 'next/server';
import { revalidateTag } from 'next/cache';
import { timingSafeEqual } from 'crypto';
export async function POST(request: NextRequest) {
const signature = request.headers.get('sanity-webhook-signature');
// Verify webhook signature
const secret = process.env.SANITY_WEBHOOK_SECRET;
if (!secret) {
return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 });
}
const expected = Buffer.from(secret);
const received = Buffer.from(signature || '');
if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
try {
const body = await request.json();
const { _type } = body;
// Revalidate cache based on content type
if (_type === 'post') {
revalidateTag('posts');
} else if (_type === 'page') {
revalidateTag('pages');
}
console.log('[Webhook] Sanity content updated:', _type); // Note: Use structured logger in production
return NextResponse.json({ revalidated: true });
} catch (error: unknown) {
console.error('[Webhook] Failed to process Sanity webhook:', error);
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 });
}
}
verbosity: minimal
Phase 5: File Upload Integration
Step 5.1: Install UploadThing SDK
precision_exec:
commands:
- cmd: "npm install uploadthing @uploadthing/react"
expect:
exit_code: 0
verbosity: minimal
Step 5.2: Create Upload Router
precision_write:
files:
- path: "src/app/api/uploadthing/core.ts"
mode: fail_if_exists
content: |
import { createUploadthing, type FileRouter } from 'uploadthing/next';
const f = createUploadthing();
export const ourFileRouter = {
imageUploader: f({ image: { maxFileSize: '4MB', maxFileCount: 4 } })
.middleware(async ({ req }) => {
// Authenticate user (placeholder imports shown for context)
// In real code: import { getUserFromRequest } from '@/lib/auth';
const user = await getUserFromRequest(req); // Import from your auth module
if (!user) throw new Error('Unauthorized');
return { userId: user.id };
})
.onUploadComplete(async ({ metadata, file }) => {
console.log('[Upload] Complete:', file.url);
// Save to database (Assumes Prisma client or similar ORM)
await db.image.create({
data: {
url: file.url,
userId: metadata.userId,
},
});
return { url: file.url };
}),
pdfUploader: f({ pdf: { maxFileSize: '16MB' } })
.middleware(async ({ req }) => {
const user = await getUserFromRequest(req);
if (!user) throw new Error('Unauthorized');
return { userId: user.id };
})
.onUploadComplete(async ({ metadata, file }) => {
console.log('[Upload] PDF complete:', file.url);
return { url: file.url };
}),
} satisfies FileRouter;
export type OurFileRouter = typeof ourFileRouter;
verbosity: minimal
Step 5.3: Alternative - S3 Presigned URLs
For more control, use S3 with presigned URLs:
precision_exec:
commands:
- cmd: "npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner"
expect:
exit_code: 0
verbosity: minimal
precision_write:
files:
- path: "src/lib/s3.ts"
mode: fail_if_exists
content: |
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
// Validate S3 configuration
if (!process.env.AWS_REGION || !process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY || !process.env.AWS_S3_BUCKET) {
throw new Error('Missing required AWS S3 environment variables');
}
const s3Client = new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
});
export async function getUploadUrl(key: string, contentType: string) {
const command = new PutObjectCommand({
Bucket: process.env.AWS_S3_BUCKET,
Key: key,
ContentType: contentType,
});
// URL expires in 5 minutes
const url = await getSignedUrl(s3Client, command, { expiresIn: 300 });
return url;
}
verbosity: minimal
Phase 6: Analytics Integration
Step 6.1: Install PostHog SDK
precision_exec:
commands:
- cmd: "npm install posthog-js"
expect:
exit_code: 0
verbosity: minimal
Step 6.2: Create Analytics Provider
precision_write:
files:
- path: "src/providers/analytics.tsx"
mode: fail_if_exists
content: |
'use client';
import { useEffect } from 'react';
import type { ReactNode } from 'react';
import posthog from 'posthog-js';
export function AnalyticsProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (process.env.NEXT_PUBLIC_POSTHOG_KEY) {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://app.posthog.com',
loaded: (posthog) => {
if (process.env.NODE_ENV === 'development') {
posthog.opt_out_capturing(); // Disabled in dev to avoid polluting analytics (re-enable for testing with posthog.opt_in_capturing())
}
},
});
}
}, []);
return <>{children}</>;
}
// Helper for tracking events
export function trackEvent(eventName: string, properties?: Record<string, unknown>) {
if (typeof window !== 'undefined') {
posthog.capture(eventName, properties);
}
}
verbosity: minimal
Phase 7: Error Handling Patterns
Step 7.1: Implement Exponential Backoff
For services with rate limits, implement retry logic:
precision_write:
files:
- path: "src/lib/retry.ts"
mode: fail_if_exists
content: |
export interface RetryOptions {
maxAttempts?: number;
initialDelayMs?: number;
maxDelayMs?: number;
backoffMultiplier?: number;
}
export async function withRetry<T>(
fn: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> {
const {
maxAttempts = 3,
initialDelayMs = 1000,
maxDelayMs = 10000,
backoffMultiplier = 2,
} = options;
let lastError: Error = new Error('All retry attempts failed');
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error: unknown) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt === maxAttempts) {
break;
}
const delayMs = Math.min(
initialDelayMs * Math.pow(backoffMultiplier, attempt - 1),
maxDelayMs
);
console.log(`[Retry] Attempt ${attempt}/${maxAttempts} failed, retrying in ${delayMs}ms`); // Note: Use structured logger in production
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
throw lastError;
}
verbosity: minimal
Step 7.2: Implement Circuit Breaker
Prevent cascading failures with a circuit breaker:
precision_write:
files:
- path: "src/lib/circuit-breaker.ts"
mode: fail_if_exists
content: |
export class CircuitBreaker {
private failureCount = 0;
private lastFailureTime: number | null = null;
private state: 'closed' | 'open' | 'half-open' = 'closed';
constructor(
private readonly failureThreshold = 5,
private readonly resetTimeoutMs = 60000
) {}
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'open') {
if (Date.now() - (this.lastFailureTime ?? 0) > this.resetTimeoutMs) {
this.state = 'half-open';
} else {
throw new Error('Circuit breaker is open');
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error: unknown) {
this.onFailure();
throw error;
}
}
private onSuccess() {
this.failureCount = 0;
this.state = 'closed';
}
private onFailure() {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.failureThreshold) {
this.state = 'open';
console.error('[CircuitBreaker] Circuit opened due to repeated failures');
}
}
}
verbosity: minimal
Phase 8: Testing Service Integrations
Step 8.1: Mock Service Responses
Create mock implementations for testing:
precision_write:
files:
- path: "src/lib/__mocks__/email.ts"
mode: fail_if_exists
content: |
import { SendEmailOptions } from '../email';
const sentEmails: Array<SendEmailOptions & { id: string }> = [];
export async function sendEmail(options: SendEmailOptions) {
const id = `mock-${Date.now()}`;
sentEmails.push({ ...options, id });
return { success: true, id };
}
export function getSentEmails() {
return sentEmails;
}
export function clearSentEmails() {
sentEmails.length = 0;
}
verbosity: minimal
Step 8.2: Implement Webhook Testing
Test webhook handlers locally:
precision_write:
files:
- path: "scripts/test-webhook.sh"
mode: fail_if_exists
content: |
#!/usr/bin/env bash
set -euo pipefail
# Requires: bash 4+
# Test Sanity webhook locally
echo "[INFO] Testing Sanity webhook..."
if curl -X POST http://localhost:3000/api/webhooks/sanity \
-H "Content-Type: application/json" \
-H "sanity-webhook-signature: $SANITY_WEBHOOK_SECRET" \
-d '{
"_type": "post",
"_id": "test-123",
"title": "Test Post"
}'; then
echo "[PASS] Webhook test successful"
else
echo "[FAIL] Webhook test failed"
exit 1
fi
verbosity: minimal
Phase 9: Validation
Run the validation script to ensure proper service integration:
precision_exec:
commands:
- cmd: "bash plugins/goodvibes/skills/outcome/service-integration/scripts/validate-services.sh ."
expect:
exit_code: 0
verbosity: standard
Common Patterns
Environment Variable Validation
Always validate required environment variables at startup:
const requiredEnvVars = ['RESEND_API_KEY', 'SANITY_PROJECT_ID'];
for (const envVar of requiredEnvVars) {
if (!process.env[envVar]) {
throw new Error(`Missing required environment variable: ${envVar}`);
}
}
Rate Limiting Outbound Requests
Implement token bucket rate limiting:
export class RateLimiter {
private tokens: number;
private lastRefill: number;
constructor(
private readonly maxTokens: number,
private readonly refillRatePerSecond: number
) {
this.tokens = maxTokens;
this.lastRefill = Date.now();
}
async acquire(): Promise<void> {
this.refill();
if (this.tokens < 1) {
const waitMs = (1 - this.tokens) * (1000 / this.refillRatePerSecond);
await new Promise(resolve => setTimeout(resolve, waitMs));
this.refill();
}
this.tokens -= 1;
}
private refill() {
const now = Date.now();
const elapsedSeconds = (now - this.lastRefill) / 1000;
const tokensToAdd = elapsedSeconds * this.refillRatePerSecond;
this.tokens = Math.min(this.maxTokens, this.tokens + tokensToAdd);
this.lastRefill = now;
}
}
Anti-Patterns to Avoid
1. Hardcoded API Keys
BAD:
const resend = new Resend('re_abc123');
GOOD:
if (!process.env.RESEND_API_KEY) {
throw new Error('RESEND_API_KEY is required');
}
const resend = new Resend(process.env.RESEND_API_KEY);
2. No Error Handling
BAD:
const result = await resend.emails.send(options);
return result.data;
GOOD:
const { data, error } = await resend.emails.send(options);
if (error) {
console.error('[Email] Send failed:', error);
throw new Error(`Email send failed: ${error.message}`);
}
return data;
3. Synchronous External Calls
BAD:
// Blocking user request
await sendEmail({ to: user.email, subject: 'Welcome' });
return res.json({ success: true });
ACCEPTABLE (for simple cases):
// Send email async
// Note: Fire-and-forget is only suitable for non-critical operations.
// For production: use a queue-based approach with retries (see BEST example below).
sendEmail({ to: user.email, subject: 'Welcome' })
.catch(err => console.error('[Email] Failed:', err));
return res.json({ success: true });
BEST (production-ready):
// Queue-based approach with retries
await emailQueue.add('welcome-email', {
to: user.email,
subject: 'Welcome'
});
return res.json({ success: true });
4. Missing Webhook Verification
BAD:
export async function POST(request: NextRequest) {
const body = await request.json();
// Process without verification
}
GOOD:
import { timingSafeEqual } from 'crypto';
export async function POST(request: NextRequest) {
const signature = request.headers.get('webhook-signature');
// Validate webhook secret
const secret = process.env.WEBHOOK_SECRET;
if (!secret) {
return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 });
}
const expected = Buffer.from(secret);
const received = Buffer.from(signature || '');
if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const body = await request.json();
// Process
}
Troubleshooting
Email Not Sending
- Verify API key is set:
printf "%s\n" "${RESEND_API_KEY:0:5}..."(bash parameter expansion to display first 5 chars) - Check API key permissions in provider dashboard
- Verify sender domain is verified
- Check rate limits in provider logs
- Enable debug logging:
resend.setDebug(true)
CMS Content Not Updating
- Verify webhook endpoint is publicly accessible
- Check webhook secret matches
- Test webhook locally with ngrok
- Verify cache revalidation is working
- Check CMS webhook logs
File Upload Failing
- Verify file size is within limits
- Check CORS configuration
- Verify authentication middleware
- Test with smaller file
- Check S3 bucket permissions (if using S3)
Analytics Events Not Tracking
- Verify API key is set
- Check ad blockers aren't blocking requests
- Verify opt-out is disabled in development
- Check browser console for errors
- Test with PostHog debug mode
Related Skills
- api-design - Type-safe API layer patterns
- authentication - Secure service access patterns
- testing-strategy - Testing service integrations
Success Criteria
- Service SDK installed and client created
- Environment variables documented in .env.example
- Error handling implemented with retries
- Webhook endpoints have signature verification
- Rate limiting configured for outbound requests
- Mock implementations for testing
- No hardcoded API keys in source code
- All validation checks pass