This guide covers how to embed the ShipMate own-voice engine into your own product using the proxy pattern. The reference implementation is the CC (control-center) dashboard at dashboard.smaluhn.com, which ships this exact pattern in production.
ShipMate exposes its own-voice content engine as a REST API. You hold a service API key (a sk-sm- Bearer token) in your backend environment. Your product authenticates its own users, then forwards generation requests to ShipMate on their behalf, injecting the key. Your users never see the key.
This pattern is called a service-key proxy. It is the same architecture that powers CC (control-center): one key, many users, zero exposure.
The key insight for multi-tenant voice is that each call to POST /api/generate takes a styleProfile object inline. You store one profile per end-user in your own DB and pass the right one per call. No ShipMate account is needed per end-user.
The CC shipmate-proxy route is the reference implementation. Its structure:
YOUR_APP/api/shipmate-proxy?path=/api/generatepath: rejects absolute URLs and off-origin paths (SSRF guard)SHIPMATE_API_URL + path with your service key injectedCC reference proxy (simplified Next.js route handler)
// app/api/shipmate-proxy/route.ts
import { type NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/session"; // your own auth
const SHIPMATE_URL = process.env.SHIPMATE_API_URL ?? "https://shipmate.so";
const SHIPMATE_KEY = process.env.SHIPMATE_API_KEY ?? "";
// SSRF guard: only allow relative, in-origin paths
function safePathOrNull(raw: string | null): string | null {
if (!raw) return null;
try {
// Reject any string that parses as an absolute URL
new URL(raw);
return null; // absolute URL - reject
} catch {
// Good: it's not an absolute URL. Ensure it starts with /api/
return raw.startsWith("/api/") ? raw : null;
}
}
export async function POST(req: NextRequest) {
const session = await getSession(req);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const path = safePathOrNull(req.nextUrl.searchParams.get("path"));
if (!path) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
const upstream = await fetch(SHIPMATE_URL + path, {
method: "POST",
headers: {
"Authorization": `Bearer ${SHIPMATE_KEY}`,
"Content-Type": "application/json",
},
body: req.body,
// @ts-expect-error: Node 18+ supports duplex
duplex: "half",
});
return new Response(upstream.body, {
status: upstream.status,
headers: { "Content-Type": "application/json" },
});
}
export async function GET(req: NextRequest) {
const session = await getSession(req);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const path = safePathOrNull(req.nextUrl.searchParams.get("path"));
if (!path) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
const url = new URL(SHIPMATE_URL + path);
req.nextUrl.searchParams.forEach((value, key) => {
if (key !== "path") url.searchParams.set(key, value);
});
const upstream = await fetch(url.toString(), {
headers: { "Authorization": `Bearer ${SHIPMATE_KEY}` },
});
return new Response(upstream.body, {
status: upstream.status,
headers: { "Content-Type": "application/json" },
});
}SHIPMATE_API_URL = https://shipmate.soSHIPMATE_API_KEY = sk-sm-...Sign up for a free account and create your first API key. The free tier gives you 50 generations per month so you can validate the full integration flow before choosing a plan.
SHIPMATE_API_KEY=sk-sm-... in your local environmentVerify the key works
curl https://shipmate.so/api/checkin/settings \
-H "Authorization: Bearer sk-sm-<your-key>"
# -> { "settings": { ... } }Copy the proxy route from the architecture section above into your codebase. Adapt the session check to your own auth library. The SSRF guard is non-optional: without it, a client can supply path=https://evil.example.com/steal and route requests anywhere.
SHIPMATE_API_URL=https://shipmate.so SHIPMATE_API_KEY=sk-sm-<your-key>
From your app's frontend (with your own session cookie)
// Fetch through YOUR proxy - the client never sees the ShipMate key
const res = await fetch("/api/shipmate-proxy?path=/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
activityData: { anchorText: "Shipped dark mode", anchorType: "commit" },
styleProfile: {
styleDescriptionText: "Direct, builder-focused, first-person.",
fewShotExamples: ["Just shipped search. Took 3 days. Lesson: index early."],
},
targetPlatforms: ["linkedin"],
}),
});
const { suggestions } = await res.json();
console.log(suggestions[0].content);When a new user onboards, collect 2-5 writing samples (existing posts, newsletter excerpts, Slack messages). Call POST /api/style/extract with those samples to get a prose style description. Store the result keyed by your user's ID.
Extract a style profile (curl via your proxy)
curl -X POST https://shipmate.so/api/style/extract \
-H "Authorization: Bearer ${SHIPMATE_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"textSamples": [
"Just shipped the analytics dashboard. Took two weeks...",
"OAuth is live. Magic links as fallback for users without passwords...",
"Dark mode shipped. Not glamorous but people keep asking for it."
]
}'Response:
{
"text": "Voice: direct and conversational, builder-first. Sentences: short,
rarely over 12 words. Contractions: frequent. Emoji: none. Technical
depth: moderate -- names features but skips implementation details.
Opens with: factual statement of what was shipped. Closes with: brief
lesson or honest observation. Vocabulary: developer-adjacent, avoids
marketing speak. Formatting: no lists, no bullets, plain prose.
Distinctive: always names the thing shipped first; time investment
often mentioned; no calls to action."
}TypeScript (Drizzle example)
// In your onboarding flow
async function onboardUser(userId: string, samples: string[]) {
const res = await fetch(SHIPMATE_API_URL + "/api/style/extract", {
method: "POST",
headers: {
Authorization: `Bearer ${SHIPMATE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ textSamples: samples }),
});
if (!res.ok) throw new Error("Style extraction failed");
const { text } = await res.json();
// Store in your own DB keyed by your user's ID.
// "fewShotExamples" are the raw samples - use as few-shot context later.
await db.insert(userStyleProfiles).values({
userId,
styleDescriptionText: text,
fewShotExamples: JSON.stringify(samples.slice(0, 3)),
extractedAt: new Date(),
});
}When generating a post, load the user's stored profile from your DB and pass it inline in the styleProfile field. One ShipMate API key serves all your users because the profile travels with each request.
Generate a post for a specific user
async function generatePostForUser(
userId: string,
entry: string,
platforms: string[],
) {
// 1. Load this user's style from your DB
const profile = await db.query.userStyleProfiles.findFirst({
where: eq(userStyleProfiles.userId, userId),
});
if (!profile) throw new Error("User has no style profile yet");
// 2. Call ShipMate with the inline profile
const res = await fetch(SHIPMATE_API_URL + "/api/generate", {
method: "POST",
headers: {
Authorization: `Bearer ${SHIPMATE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
activityData: { anchorText: entry, anchorType: "check_in" },
styleProfile: {
styleDescriptionText: profile.styleDescriptionText,
fewShotExamples: JSON.parse(profile.fewShotExamples),
},
targetPlatforms: platforms,
}),
});
const { suggestions } = await res.json();
return suggestions; // [{ platform, content, status, id }]
}For agencies or B2B platforms with many clients, the same pattern scales. Each client (or end-user) has their own row in your user_style_profilestable. The ShipMate API is stateless with respect to profiles: you supply the full profile on every request.
Agency: extract profiles for multiple clients
const clients = [
{ id: "alice", samples: ["Alice's posts..."] },
{ id: "bob", samples: ["Bob's posts..."] },
];
await Promise.all(clients.map(({ id, samples }) => onboardUser(id, samples)));
// Each client now has their own voice profile stored in your DB.Batch generation across clients
const results = await Promise.all(
clients.map(({ id }) =>
generatePostForUser(id, "Shipped weekly client newsletter", ["linkedin"])
)
);
// results[0] = Alice's LinkedIn draft in Alice's voice
// results[1] = Bob's LinkedIn draft in Bob's voiceCall POST /api/style/extract again with new samples and overwrite the row. The profile evolves as the user writes more. There is no ShipMate-side state to manage.
ShipMate supports a revision loop: pass the previous draft back via refineContext.previousDraft along with critique chips or freeform guidance in immediateGuidance. The engine revises rather than regenerates from scratch.
Refine a draft
const refinement = await fetch(SHIPMATE_API_URL + "/api/generate", {
method: "POST",
headers: {
Authorization: `Bearer ${SHIPMATE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
activityData: { anchorText: "Shipped dark mode", anchorType: "commit" },
styleProfile: {
styleDescriptionText: profile.styleDescriptionText,
fewShotExamples: JSON.parse(profile.fewShotExamples),
},
targetPlatforms: ["linkedin"],
// Built-in chips: too_long, too_confident, too_salesy, wrong_angle, off_brand
immediateGuidance: ["too_long", "too_salesy"],
refineOfPostId: "post-uuid-from-previous-call",
refineContext: {
previousDraft: "Just shipped dark mode. This was a long journey...",
},
}),
});
const { suggestions } = await refinement.json();
// suggestions[0].content is the revised draftWhen a user approves or rejects a post, send that signal back via PATCH /api/generate with the post id, status, and optionally editedContent (the final text after their edits). The engine learns from edits over time and proposes standing style rules when a pattern repeats.
Limits apply per API key (i.e. per your service key, across all your end-users combined). Plan accordingly.
| Endpoint | Limit | Headers |
|---|---|---|
POST /api/generate | 10 req / min (Free) | X-RateLimit-Remaining, Retry-After |
POST /api/style/extract | 10 req / min | Retry-After |
POST /api/activity | 30 req / min | Retry-After |
Other endpoints | No explicit limit | - |
When you hit a rate limit the API returns 429 with a Retry-After header (seconds). Implement exponential backoff in your proxy before surfacing the error to your user.
On the free tier, generation quota is 50 requests per month. Over-quota calls return 402. Upgrade to Retail (1,000/mo) or Business (10,000/mo) to scale. Enterprise plans have custom quotas.
If you need custom quota, dedicated support, SLA commitments, or white-label options, we scope a plan together. Fill out the form on the OEM section of the platform page or email [email protected].
What to include in your inquiry