Next.js partner integration
This guide is for a product that already has a post composer, a calendar and a post record of its own, and wants MissLess as the engine underneath. The code is Next.js App Router with TypeScript. The backend parts translate directly to any server; nothing here depends on a framework feature beyond a route handler.
What you will end up with:
- A workspace per member, keyed by your user id.
- A route that mints a connect link and redirects.
- A
MissLessPublisherclass behind your existingSocialPublisherinterface. - A webhook receiver that verifies signatures and updates your post record.
- An embed-session route and an iframe for the accounts screen.
Two environment variables on the server: MISSLESS_API_KEY (your partner key) and MISSLESS_WEBHOOK_SECRET (from step 4).
0. A tiny client
Section titled “0. A tiny client”One function wraps fetch, adds the key, and turns API errors into a typed exception.
const BASE = 'https://social.missless.tel/v1';
export class MissLessError extends Error { constructor(public status: number, public code: string, message: string, public field?: string) { super(message); }}
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> { const res = await fetch(`${BASE}${path}`, { ...init, headers: { Authorization: `Bearer ${process.env.MISSLESS_API_KEY}`, 'Content-Type': 'application/json', ...(init.headers ?? {}), }, cache: 'no-store', }); if (res.status === 204) return undefined as T; const body = await res.json().catch(() => ({})); if (!res.ok) { const e = body?.error ?? {}; throw new MissLessError(res.status, e.code ?? 'unknown', e.message ?? res.statusText, e.field); } return body as T;}
export type Workspace = { id: string; external_id: string | null; name: string; timezone: string };export type Account = { id: string; network: 'facebook' | 'instagram'; username: string; status: 'active' | 'expired' | 'revoked' | 'error' };export type Target = { account: string; network: string; username: string; status: string; platform_post_id: string | null; permalink: string | null; error: string | null; published_at: string | null;};export type Post = { id: string; external_ref: string | null; status: string; targets: Target[]; last_error: string | null };1. A workspace per member
Section titled “1. A workspace per member”POST /v1/workspaces is an upsert on external_id, so you can call it whenever you need the workspace and never store the id if you do not want to. Storing it saves a call; do that on first use.
import { api, type Workspace } from './missless';
export async function ensureWorkspace(user: { id: string; businessName: string; timezone?: string }): Promise<Workspace> { const ws = await api<Workspace>('/workspaces', { method: 'POST', body: JSON.stringify({ external_id: user.id, name: user.businessName, timezone: user.timezone ?? 'Europe/Amsterdam' }), }); await db.user.update({ where: { id: user.id }, data: { misslessWorkspaceId: ws.id } }); // your ORM return ws;}
export async function findWorkspace(userId: string): Promise<Workspace | null> { const list = await api<{ data: Workspace[] }>(`/workspaces?external_id=${encodeURIComponent(userId)}&limit=1`); return list.data[0] ?? null;}Deleting a member? DELETE /v1/workspaces/:id disconnects their accounts and removes their posts, media and inbox on the MissLess side. Call it from your account-deletion flow.
2. The connect route
Section titled “2. The connect route”A member clicks Connect your accounts. Your route mints a connect link for their workspace and redirects. redirect_url brings them back to your settings page with ?connected=<n>.
import { NextResponse } from 'next/server';import { api } from '@/lib/social/missless';import { ensureWorkspace } from '@/lib/social/workspace';import { requireUser } from '@/lib/auth';
export async function GET(req: Request) { const user = await requireUser(req); const workspace = await ensureWorkspace(user);
const link = await api<{ token: string; url: string; expires_at: string }>(`/workspaces/${workspace.id}/connect-links`, { method: 'POST', body: JSON.stringify({ networks: ['facebook', 'instagram'], redirect_url: new URL('/settings/social', req.url).toString(), expires_in: 900, }), });
return NextResponse.redirect(link.url);}The button is a plain link to /api/social/connect. On the way back, read connected from the query string and refetch accounts, or embed the accounts surface from step 5 and let it refresh itself.
3. The publisher
Section titled “3. The publisher”Your product already has an interface like this, with one implementation per engine:
export interface PublishInput { caption: string; mediaUrls: string[]; channels: string[]; ownerUserId?: string }export type ChannelResult = { ok: boolean; id?: string; error?: string }export type PublishResult = Record<string, ChannelResult>export interface SocialPublisher { publish(input: PublishInput): Promise<PublishResult> }MissLessPublisher implements it. It maps ownerUserId to a workspace, uploads mediaUrls by URL, resolves channels ("instagram", "facebook") to account ids, publishes with ?wait=true, and maps the targets back. It also accepts an optional externalRef, your own post id, so the webhook in step 4 can find the record later; the extra optional parameter keeps it assignable to SocialPublisher.
import { api, MissLessError, type Account, type Post } from '@/lib/social/missless';import { findWorkspace } from '@/lib/social/workspace';import type { PublishInput, PublishResult, SocialPublisher } from './social';
export class MissLessPublisher implements SocialPublisher { async publish(input: PublishInput, opts: { externalRef?: string } = {}): Promise<PublishResult> { const result: PublishResult = Object.fromEntries(input.channels.map((c) => [c, { ok: false, error: 'not attempted' }]));
if (!input.ownerUserId) return failAll(result, 'ownerUserId is required'); const workspace = await findWorkspace(input.ownerUserId); if (!workspace) return failAll(result, 'no workspace for this user; connect accounts first');
// channels -> account ids const accounts = await api<{ data: Account[] }>(`/workspaces/${workspace.id}/accounts`); const channelByAccount = new Map<string, string>(); for (const channel of input.channels) { const account = accounts.data.find((a) => a.network === channel && a.status === 'active'); if (!account) { result[channel] = { ok: false, error: `no active ${channel} account connected` }; continue; } channelByAccount.set(account.id, channel); } const targets = [...channelByAccount.keys()]; if (targets.length === 0) return result; const attempted = [...channelByAccount.values()];
// media URLs -> media ids let media: string[]; try { media = await Promise.all( input.mediaUrls.map(async (url) => { const m = await api<{ id: string }>('/media', { method: 'POST', body: JSON.stringify({ workspace: workspace.id, url }) }); return m.id; }), ); } catch (err) { return failAll(result, `media upload failed: ${describe(err)}`, attempted); }
// create and publish, wait up to 25 s for the outcome let post: Post; try { post = await api<Post>('/posts?wait=true', { method: 'POST', body: JSON.stringify({ workspace: workspace.id, caption: input.caption, media, targets, external_ref: opts.externalRef, }), }); } catch (err) { return failAll(result, describe(err), attempted); }
// targets -> PublishResult for (const target of post.targets) { const channel = channelByAccount.get(target.account); if (!channel) continue; if (target.status === 'published') { result[channel] = { ok: true, id: target.platform_post_id ?? post.id }; } else if (target.status === 'failed') { result[channel] = { ok: false, error: target.error ?? post.last_error ?? 'failed' }; } else { // still publishing after 25 s (Reels): the webhook will finish the job result[channel] = { ok: false, id: post.id, error: 'pending' }; } } return result; }}
function failAll(result: PublishResult, error: string, channels: string[] = Object.keys(result)): PublishResult { for (const c of channels) result[c] = { ok: false, error }; return result;}
function describe(err: unknown): string { if (err instanceof MissLessError) return `${err.code}: ${err.message}`; return err instanceof Error ? err.message : String(err);}Notes on the mapping:
- A channel with no active account fails on its own and does not block the others.
account_disconnectedfrom the API is avoided by filtering onstatus === 'active'first. - Instagram needs media, and JPEG only. If
mediaUrlsis empty, or contains a PNG, WEBP or GIF, andchannelsincludesinstagram, the API returnsvalidation_erroronmedia; it lands in theerrorof every attempted channel. Check for it earlier in your composer if you want a friendlier message. pendingmeans Meta is still processing (Reels do this). Keep the MissLess post id and let the webhook set the final state. If you prefer not to wait at all, drop?wait=trueand treat every channel as pending.- Per-network captions: pass
targetsas{ account, caption }objects instead of ids when your composer has a per-channel text field. See Posts.
Calling it from your existing publish flow:
const publisher = new MissLessPublisher();const results = await publisher.publish( { caption: post.caption, mediaUrls: post.media.map((m) => m.url), channels: post.channels, ownerUserId: post.ownerId }, { externalRef: post.id },);await db.socialPost.update({ where: { id: post.id }, data: { results, status: summarize(results) } });4. The webhook receiver
Section titled “4. The webhook receiver”Register the endpoint once with PUT /v1/webhook (see Webhook setup) and put the returned secret in MISSLESS_WEBHOOK_SECRET.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyMissLess(rawBody: string, headers: Headers, secret: string, tolerance = 300): boolean { const timestamp = headers.get('x-missless-timestamp') ?? ''; const signature = headers.get('x-missless-signature') ?? ''; if (!timestamp || !signature || !secret) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)); if (!Number.isFinite(age) || age > tolerance) return false;
const expected = 'sha256=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(signature); return a.length === b.length && timingSafeEqual(a, b);}import { NextResponse } from 'next/server';import { verifyMissLess } from '@/lib/social/verify';import type { Post } from '@/lib/social/missless';
export const runtime = 'nodejs';
export async function POST(req: Request) { const raw = await req.text(); // raw body: the signature covers these exact bytes if (!verifyMissLess(raw, req.headers, process.env.MISSLESS_WEBHOOK_SECRET ?? '')) { return new NextResponse(null, { status: 401 }); } const event = JSON.parse(raw) as { id: string; type: string; workspace: string | null; external_id: string | null; data: any };
// idempotency: X-MissLess-Delivery equals event.id on every retry const fresh = await db.processedEvent.create({ data: { id: event.id } }).then(() => true).catch(() => false); if (!fresh) return new NextResponse(null, { status: 200 });
switch (event.type) { case 'post.published': case 'post.partial': case 'post.failed': { const post = event.data.post as Post; if (!post.external_ref) break; const results = Object.fromEntries( post.targets.map((t) => [ t.network, t.status === 'published' ? { ok: true, id: t.platform_post_id ?? post.id, url: t.permalink } : { ok: false, error: t.error ?? 'failed' }, ]), ); await db.socialPost.update({ where: { id: post.external_ref }, data: { status: post.status, results } }); break; } case 'account.connected': case 'account.disconnected': case 'account.expired': if (event.external_id) await db.user.update({ where: { id: event.external_id }, data: { socialAccountsStale: true } }); break; }
return new NextResponse(null, { status: 200 });}The handler returns 200 quickly and does only database writes. If you need to send emails or call other services on post.failed, enqueue them.
5. The accounts screen
Section titled “5. The accounts screen”Rather than listing accounts yourself, embed the accounts surface. It shows each connected account with status, has the Connect button, and lets the member disconnect. You mint a session server-side and render an iframe.
import { NextResponse } from 'next/server';import { api } from '@/lib/social/missless';import { ensureWorkspace } from '@/lib/social/workspace';import { requireUser } from '@/lib/auth';
export async function POST(req: Request) { const user = await requireUser(req); const workspace = await ensureWorkspace(user);
const session = await api<{ token: string; expires_at: string; urls: Record<string, string> }>('/embed-sessions', { method: 'POST', body: JSON.stringify({ workspace: workspace.id, surfaces: ['accounts'], expires_in: 1800, theme: { mode: user.theme === 'light' ? 'light' : 'dark', accent: '#6d5efc', radius: '12px' }, locale: user.locale === 'nl' ? 'nl' : 'en', }), });
return NextResponse.json({ url: session.urls.accounts, expires_at: session.expires_at });}'use client';import { useEffect, useRef, useState } from 'react';
export function SocialAccounts() { const [url, setUrl] = useState<string | null>(null); const frame = useRef<HTMLIFrameElement>(null);
useEffect(() => { fetch('/api/social/embed-session', { method: 'POST' }) .then((r) => r.json()) .then((s) => setUrl(s.url)); }, []);
useEffect(() => { const onMessage = (e: MessageEvent) => { if (e.origin !== 'https://social.missless.tel' || e.data?.source !== 'missless-social') return; if (e.data.event === 'resize' && frame.current) frame.current.style.height = `${e.data.data.height}px`; if (e.data.event === 'account.connected') fetch('/api/social/accounts/refresh', { method: 'POST' }); }; window.addEventListener('message', onMessage); return () => window.removeEventListener('message', onMessage); }, []);
if (!url) return <div className="rounded-xl border p-6 text-sm opacity-60">Loading social accounts</div>; return <iframe ref={frame} src={url} title="Connected social accounts" style={{ width: '100%', height: 420, border: 0 }} />;}With the surface restricted to accounts, the session cannot open the composer or inbox even if the URL is edited. Your composer stays yours.
Checklist before going live
Section titled “Checklist before going live”- Partner key and webhook secret in server-side secrets only.
PUT /v1/webhookdone against production,POST /v1/webhook/testverified.- Your post record stores results per channel and tolerates
pending. - Account deletion calls
DELETE /v1/workspaces/:id. - Your composer blocks Instagram without media, Instagram with non-JPEG images, and captions over 2,200 characters, so members see a clear message instead of an API error. See Instagram rules.
- Members who want to connect have read Requirements, or your help text repeats it.