Skip to content

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:

  1. A workspace per member, keyed by your user id.
  2. A route that mints a connect link and redirects.
  3. A MissLessPublisher class behind your existing SocialPublisher interface.
  4. A webhook receiver that verifies signatures and updates your post record.
  5. 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).

One function wraps fetch, adds the key, and turns API errors into a typed exception.

lib/social/missless.ts
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 };

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.

lib/social/workspace.ts
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.

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>.

app/api/social/connect/route.ts
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.

Your product already has an interface like this, with one implementation per engine:

lib/integrations/social.ts
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.

lib/integrations/missless-publisher.ts
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_disconnected from the API is avoided by filtering on status === 'active' first.
  • Instagram needs media, and JPEG only. If mediaUrls is empty, or contains a PNG, WEBP or GIF, and channels includes instagram, the API returns validation_error on media; it lands in the error of every attempted channel. Check for it earlier in your composer if you want a friendlier message.
  • pending means 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=true and treat every channel as pending.
  • Per-network captions: pass targets as { 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) } });

Register the endpoint once with PUT /v1/webhook (see Webhook setup) and put the returned secret in MISSLESS_WEBHOOK_SECRET.

lib/social/verify.ts
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);
}
app/api/webhooks/missless/route.ts
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.

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.

app/api/social/embed-session/route.ts
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 });
}
app/settings/social/SocialAccounts.tsx
'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.

  • Partner key and webhook secret in server-side secrets only.
  • PUT /v1/webhook done against production, POST /v1/webhook/test verified.
  • 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.