Building a white-label social media scheduling tool for your clients involves more than slapping a logo on someone else's dashboard. You're assembling a multi-tenant system with per-client OAuth credentials, a scheduling engine that survives DST transitions and rate limits, webhook handlers that can't double-process events, and an approval workflow that enforces RBAC down to the post level. This guide covers every layer: architecture, data model, BYO OAuth onboarding, token refresh, scheduling queue design, webhook reliability, and a runnable TypeScript integration built on Outstand's unified API.
If you're evaluating whether to build vs. resell an off-the-shelf tool, stay for the first two sections. If you already know you're building, jump straight to Architecture.
What a white-label social scheduler actually is (and what you must implement yourself)
A white-label social media scheduler has four non-negotiable layers:
- Branded UX - your domain, your colors, your login screen. No third-party branding anywhere a client can see.
- Tenant isolation - every client's posts, credentials, and analytics are strictly partitioned from every other client's data.
- Client-connected social credentials - each client authenticates their own social accounts, and those tokens are stored and refreshed under your control.
- Scheduling/dispatch engine - a worker that moves approved posts from a queue to the relevant social platforms at the right time, handles failures, and updates status back to your database.
Two build paths
Path A is reselling or hosting a white-label SaaS product. You configure branding, set up subdomains, and pay a per-seat or flat monthly fee. You skip the engineering but accept the vendor's feature roadmap and data model.
Path B is building your own scheduler UI and backend on top of a social media API like Outstand. You own the product, the UX, the data, and the approval logic. Outstand handles the hard parts: unified posting across 10+ platforms for $0.01 per post, intelligent rate limiting, token refreshes, media processing, and webhooks that are signed, retried, and ordered.
What Outstand provides vs. what your app must own
Outstand provides | Your app must own |
|---|---|
Unified | Tenant/workspace data model |
| Approval workflow and state machine |
Signed, retried, ordered webhooks ( | Job queue and worker orchestration |
BYO app credentials (your OAuth screen, not Outstand's) | RBAC and role definitions |
Intelligent rate limiting and retry handling | Idempotency keys and dedup logic |
Media processing and platform-specific optimization | Audit logs, reporting UI, client portal |
The division is clean: Outstand absorbs platform complexity; you own the product layer.
Architecture: multi-tenant scheduler backend
The reference architecture for an agency-grade white-label social scheduler looks like this:
Client Portal / Agency Console (Next.js, React, etc.)
|
v
Your Backend API (REST or GraphQL)
|
+---> Job Queue / Worker (BullMQ, Temporal, SQS + Lambda)
| |
| v
| Outstand API (/v1/posts, /v1/social-accounts)
| |
| v
+---> Webhook Handler (receives post.published, post.error)
|
v Persistence Layer (PostgreSQL / Planetscale)
The web app handles UX (agency console for your team, client portal for their approvals). Your backend API validates requests, enforces RBAC, and writes to the database. The job queue worker picks up dispatchable posts, calls Outstand's /v1/posts with scheduledAt, persists the returned postId, and handles retries. The webhook handler receives Outstand events, verifies signatures, and drives state transitions in your posts table.
Tenant boundary strategy
You have three options:
- Shared database +
tenant_idcolumn + row-level security (RLS): Simplest to operate, lowest cost. Every query must be scoped bytenant_id. PostgreSQL RLS policies can enforce this at the database layer, but you must set theapp.current_tenantsession variable correctly on every connection. - Separate schema per tenant: Stronger isolation. Easier migrations per tenant. More complex connection pooling. Reasonable for 10-100 clients.
- Separate database per tenant: Strongest isolation. Significant operational overhead. Only justified for enterprise clients with contractual data residency requirements.
For most agency deployments, shared DB + tenant_id + RLS is the right call. Enforce scoping at the ORM layer as a secondary control so no query ever reaches production without a tenant constraint.
Minimal data model
-- Core tenant/workspace table
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT UNIQUE NOT NULL, -- used in subdomains
brand_name TEXT NOT NULL,
approval_required BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Users scoped to a tenant with a role
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN (
'agency_admin', 'team_member', 'client_admin', 'client_viewer'
)),
created_at TIMESTAMPTZ DEFAULT now()
);
-- Connected social accounts, one row per platform account per tenant
CREATE TABLE social_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
outstand_social_account_id TEXT NOT NULL, -- stable ID from Outstand
platform TEXT NOT NULL,
display_name TEXT,
status TEXT NOT NULL CHECK (status IN ('active', 'needs_reauth', 'disconnected')),
created_at TIMESTAMPTZ DEFAULT now()
);
-- Posts with full workflow state
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
outstand_post_id TEXT, -- populated after dispatch
content TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN (
'draft', 'submitted_for_approval', 'approved',
'scheduled', 'publishing', 'published', 'failed', 'cancelled'
)),
scheduled_at TIMESTAMPTZ, -- UTC
scheduled_tz TEXT, -- original client timezone, e.g. 'America/New_York'
idempotency_key TEXT UNIQUE, -- prevents double-dispatch on retries
created_by UUID REFERENCES users(id),
approved_by UUID REFERENCES users(id),
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Webhook event log for idempotent processing
CREATE TABLE webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID,
event_id TEXT UNIQUE NOT NULL, -- Outstand's event identifier
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
processed_at TIMESTAMPTZ DEFAULT now()
);
-- Approval audit records
CREATE TABLE approval_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL,
action TEXT NOT NULL CHECK (action IN ('submitted', 'approved', 'rejected')),
actor_id UUID REFERENCES users(id),
note TEXT,
created_at TIMESTAMPTZ DEFAULT now());
Every query against any of these tables should carry a WHERE tenant_id = $tenantId predicate. Build a query helper that enforces this at the ORM level so it can't be accidentally omitted. See the backend integration guide for patterns on storing Outstand IDs alongside your own.
BYO OAuth / client onboarding checklist
One of Outstand's most important white-label features is BYO app credentials: "Bring your own X/LinkedIn/Meta apps so your customers see your brand on the OAuth screen, not ours." This means your clients authorize your registered app, not Outstand's, which is essential for a credible white-label product.
Onboarding flow, step by step
- Create the tenant record - provision a row in your
tenantstable, assign a subdomain, store brand settings. - Collect brand and approval settings - logo URL, primary color, approval_required flag, default platforms.
- Store BYO credentials - for each platform your client needs, store the
clientId/clientSecretof your registered OAuth app encrypted at rest (see token management below). - Initiate platform connection - call Outstand's social network authentication URL endpoint to generate an OAuth URL, then redirect the client user through your branded consent screen.
- Handle the OAuth callback - exchange the code, confirm the connection via Outstand's connect a new social network endpoint, and persist the returned
socialAccountIdin yoursocial_accountstable. - Verify scopes and permissions - call get details of a social network and confirm the required scopes are granted.
- Mark the account as
active- updatesocial_accounts.statusand notify the agency admin.
Progress states to surface in your UI
not_started → oauth_initiated → oauth_callback_received
→ scope_verified → active (or → needs_reauth on any failure)
Show this state to the agency admin in their console so they can see exactly where a client is in the onboarding funnel. Don't hide async platform errors.
OAuth gotchas
- Consent screen branding: The registered OAuth app name and logo must match what the client expects to see. Register per-platform apps under your agency's developer account or the client's, depending on contractual ownership.
- Redirect URL management: Your OAuth redirect URL must be registered in each platform's app dashboard. Use a single canonical redirect endpoint (
/auth/callback) that dispatches bystateparameter, not separate URLs per platform. - Reconnect flows: Token expiry or permission revocation triggers
needs_reauth. Your UI must surface a one-click reconnect that replays the OAuth flow and updates the stored token without creating a duplicatesocial_accountsrow. - Permissions drift: Platforms occasionally add required scopes after initial authorization. Build a scope-check on every dispatch attempt that compares required scopes against stored granted scopes and flags mismatches before attempting to publish.
Token management and refresh strategies
OAuth token handling is where multi-tenant schedulers most commonly break in production. The failure modes are predictable: refresh token storms, race conditions on concurrent refreshes, and stale tokens silently causing publish failures at 2am.
Avoid refresh storms with distributed locking
When multiple worker processes or scheduled jobs attempt to refresh the same token simultaneously, you get N refresh requests against the platform API, all but one of which will fail with an invalid_grant error. The surviving one invalidates all the others.
The fix is a distributed lock keyed by tenant_id + social_account_id. Before any refresh attempt:
- Acquire a lock with a TTL (e.g., 30 seconds) from Redis or your distributed lock service.
- Re-read the token from the database inside the lock. If another worker already refreshed it within the last N seconds, skip the refresh and use the stored token.
- Perform the refresh. Write the new token. Release the lock.
This pattern ensures exactly one refresh attempt per account per token lifetime window.
Storage requirements
Per RFC 9700 ("OAuth 2.0 Security Best Current Practice"), refresh tokens must be stored server-side with encryption at rest. Specifically:
- Never expose refresh tokens to the browser or include them in API responses to the frontend.
- Encrypt tokens using AES-256-GCM before writing to the database. Store encryption keys in a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager), not in environment variables in your repo.
- Rotate encryption keys on a defined schedule and support re-encryption of stored tokens during rotation.
Lifecycle on refresh failure
When a refresh attempt fails after your retry budget (e.g., 3 attempts with exponential backoff):
- Set
social_accounts.status = 'needs_reauth'. - Cancel or hold all
scheduledposts for that social account (set to aheldsub-status rather than deleting). - Notify the tenant's
client_adminand theagency_adminvia email/in-app notification. - Block the dispatch worker from attempting to publish against that account until status returns to
active.
Scheduling design: timezones, queues, retries, idempotency
Timezones done right
Store two fields on every post: scheduled_at in UTC (the absolute moment the worker should dispatch) and scheduled_tz as the client's original timezone string (e.g., America/New_York). You need the original timezone for:
- Displaying the scheduled time back to the client in their local time.
- Handling DST transitions correctly. When a user schedules a post for "9:00 AM Eastern" during EDT (UTC-4), the UTC value is 13:00. If the same post recurs weekly and a DST transition happens, you must recompute from the timezone string, not from a fixed UTC offset.
Never store timezone offsets as integers. Always store IANA timezone identifiers.
Queue semantics
A post becomes "dispatchable" when its status is approved and scheduled_at <= now(). Your worker should:
- Poll or subscribe to a queue for dispatchable posts (e.g., a sorted set in Redis keyed by
scheduled_at, or a cron that queriesWHERE status = 'approved' AND scheduled_at <= now()). - For each dispatchable post, set status to
publishingbefore calling Outstand. This prevents concurrent workers from picking up the same post. - Call
POST /v1/postson Outstand with the content, the tenant-scopedsocialAccountIds, and thescheduledAtfield. Outstand handles the fan-out to multiple platforms in a single request. - Persist the returned
outstand_post_idto yourpoststable for webhook correlation. - On Outstand-side error, apply your retry policy (see below).
Using Outstand's scheduledAt for near-future posts means Outstand manages the platform-side queuing; for immediate dispatch, omit scheduledAt or set it to now().
Retry policy
Distinguish three failure classes:
- Rate-limit (HTTP 429): Retry with exponential backoff + jitter. Outstand handles platform-level rate limits internally, so a 429 from Outstand is rare; treat it as a signal to back off your request rate.
- Transient (HTTP 5xx, network timeout): Retry up to 5 times with backoff. After 5 failures, set post status to
failedand enqueue to DLQ. - Permanent (HTTP 400, invalid content, revoked token): Don't retry. Set status to
failed, log the error detail, and surface it to the agency admin.
Idempotency
Generate a unique idempotency_key per post at creation time (e.g., UUID v4). Pass this as a header or parameter on your Outstand POST /v1/posts call. If your worker crashes after calling Outstand but before persisting the outstand_post_id, a retry with the same idempotency_key will return the existing post rather than creating a duplicate. Store the idempotency_key in your posts table so it survives worker restarts.
Approval workflow coupling
Only transition a post from approved to scheduled (i.e., enqueue it for dispatch) after the approval record is written and committed. Use a database transaction:
BEGIN;
UPDATE posts SET status = 'approved', approved_by = $userId, approved_at = now()
WHERE id = $postId AND tenant_id = $tenantId AND status = 'submitted_for_approval';
INSERT INTO approval_records (post_id, tenant_id, action, actor_id)
VALUES ($postId, $tenantId, 'approved', $userId);
COMMIT;-- Only after commit: enqueue to dispatch worker
This prevents a post from being dispatched if the approval transaction rolls back.
Webhook handling and delivery guarantees
Outstand's webhooks are signed, retried, and ordered. "Signed" means each payload carries a signature you can verify. "Retried" means Outstand will redeliver if your endpoint doesn't return a 2xx, which makes delivery semantics at-least-once. Your handler must therefore be idempotent: processing the same event twice must produce the same outcome as processing it once.
See the Outstand webhooks documentation and post lifecycle reference for the full event schema.
Implementation blueprint
1. Receive POST to /webhooks/outstand
2. Verify HMAC signature (fail fast with 401 if invalid)
3. Return 200/202 immediately (before any DB writes)
4. Enqueue raw payload to internal async queue
5. Worker dequeues and processes:
a. Check webhook_events table for event_id (dedup)
b. If already processed, skip and ack
c. Otherwise, run state transition inside a transaction d. Insert into webhook_events inside same transaction
Returning 200 immediately (step 3) before doing any work is critical. If your handler does slow DB operations synchronously and times out, Outstand retries the delivery, and you'll process the event multiple times without dedup protection.
Deduplication strategy
-- Inside your webhook worker, within a transaction:
INSERT INTO webhook_events (event_id, tenant_id, event_type, payload)
VALUES ($eventId, $tenantId, $eventType, $payload)
ON CONFLICT (event_id) DO NOTHING;
-- Check if the row was actually inserted
GET DIAGNOSTICS inserted = ROW_COUNT;
IF inserted = 0 THEN
-- Already processed; return early
END IF;
-- Safe to apply state transition
UPDATE posts SET status = 'published'
WHERE outstand_post_id = $outstandPostId
AND tenant_id = $tenantId AND status != 'published'; -- idempotent guard
The ON CONFLICT DO NOTHING pattern plus the status != 'published' guard means duplicate deliveries are silently dropped without side effects.
State transitions from webhook events
Outstand event | Your posts table transition |
|---|---|
|
|
|
|
Social account event (permission revoked) | Update |
Dead letter queue (DLQ) and operational tooling
When your webhook worker fails to process an event after N retries (e.g., database is down, unexpected payload shape), move it to a DLQ. Your DLQ should:
- Preserve the original payload and all retry attempt metadata.
- Alert on-call when queue depth exceeds a threshold.
- Expose a replay endpoint that re-enqueues specific events by
event_idafter you've fixed the root cause.
Also monitor webhook lag: the delta between Outstand's event created_at timestamp and your processed_at. If lag exceeds your SLA (e.g., 60 seconds), alert before clients notice. Refer to the backend integration guide for additional webhook handler patterns.
Approval workflow and roles
Role definitions
Role | Permissions |
|---|---|
| Full access across all tenants; can override approvals |
| Create and edit posts; submit for approval; view all client tenants they're assigned to |
| Approve/reject posts; connect social accounts; view analytics for their tenant |
| Read-only access to scheduled posts and analytics for their tenant |
Optionally split client_admin into approver and creator sub-roles if clients want internal approval chains before content reaches the agency.
Workflow state machine
draft
└─(submit)──> submitted_for_approval
├─(approve)──> approved ──> scheduled ──> publishing
│ ├──> published
│ └──> failed
└─(reject)───> draft
(any state) ──(cancel)──> cancelled
Approval gates dispatch: a worker must check status = 'approved' before calling Outstand. Never derive dispatchability from the scheduled_at timestamp alone; the approval flag is an independent, required condition.
UI requirements for the approval layer
- Show a diff/preview of post content per platform (character count, media preview, hashtag rendering).
- Per-platform validation messages before submission (e.g., Twitter/X character limit, Instagram aspect ratio).
- Audit trail: who submitted, who approved, timestamps, any rejection notes.
- A notification system that pings the right role when action is required (e.g.,
client_adminnotified on new submission;team_membernotified on approval or rejection).
End-to-end code sample: create scheduled post → handle webhook → mark published
This TypeScript skeleton mirrors the Outstand getting started flow and implements the full dispatch-to-confirmation cycle. It assumes you've already set up the data model above.
import crypto from 'crypto';
import { db } from './db'; // your database client
import { queue } from './queue'; // your job queue
const OUTSTAND_API_KEY = process.env.OUTSTAND_API_KEY!;
const OUTSTAND_WEBHOOK_SECRET = process.env.OUTSTAND_WEBHOOK_SECRET!;
const OUTSTAND_BASE = 'https://api.outstand.so';
// -------------------------------------------------------
// Step 1: List connected social accounts (tenant-scoped)
// -------------------------------------------------------
async function listSocialAccounts(tenantId: string) {
const res = await fetch(`${OUTSTAND_BASE}/v1/social-accounts`, {
headers: { Authorization: `Bearer ${OUTSTAND_API_KEY}` },
});
const { data } = await res.json();
// Filter to accounts your tenant has registered
const tenantAccountIds = await db.query(
`SELECT outstand_social_account_id FROM social_accounts
WHERE tenant_id = $1 AND status = 'active'`,
[tenantId]
);
const activeIds = new Set(tenantAccountIds.rows.map((r: any) => r.outstand_social_account_id));
return data.filter((a: any) => activeIds.has(a.id));
}
// -------------------------------------------------------
// Step 2: Dispatch an approved post via Outstand
// -------------------------------------------------------
async function dispatchPost(postId: string, tenantId: string) {
const post = await db.queryOne(
`SELECT * FROM posts WHERE id = $1 AND tenant_id = $2 AND status = 'approved'`,
[postId, tenantId]
);
if (!post) throw new Error('Post not found or not in approved state');
const accounts = await listSocialAccounts(tenantId);
const socialAccountIds = accounts.map((a: any) => a.id);
// Mark as publishing before calling Outstand (prevents double-dispatch)
await db.query(
`UPDATE posts SET status = 'publishing' WHERE id = $1 AND tenant_id = $2`,
[postId, tenantId]
);
const res = await fetch(`${OUTSTAND_BASE}/v1/posts`, {
method: 'POST',
headers: {
Authorization: `Bearer ${OUTSTAND_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': post.idempotency_key, // prevents double-post on retry
},
body: JSON.stringify({
content: post.content,
socialAccountIds,
scheduledAt: post.scheduled_at, // ISO 8601 UTC
}),
});
if (!res.ok) {
const err = await res.json();
await db.query(
`UPDATE posts SET status = 'failed' WHERE id = $1 AND tenant_id = $2`,
[postId, tenantId]
);
throw new Error(`Outstand dispatch failed: ${JSON.stringify(err)}`);
}
const { data } = await res.json();
// Step 3: Persist the Outstand post ID for webhook correlation
await db.query(
`UPDATE posts SET outstand_post_id = $1, status = 'scheduled'
WHERE id = $2 AND tenant_id = $3`,
[data.id, postId, tenantId]
);
return data.id;
}
// -------------------------------------------------------
// Step 4: Webhook endpoint (Express / Fastify route handler)
// -------------------------------------------------------
async function handleOutstandWebhook(req: any, res: any) {
// Verify HMAC signature
const signature = req.headers['x-outstand-signature'] as string;
const rawBody = req.rawBody as Buffer;
const expected = crypto
.createHmac('sha256', OUTSTAND_WEBHOOK_SECRET)
.update(rawBody)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}
// Return 200 immediately — process async
res.status(202).send('Accepted');
// Enqueue for async processing
await queue.add('process-webhook', { payload: req.body });
}
// -------------------------------------------------------
// Step 5: Webhook worker — idempotent state transitions
// -------------------------------------------------------
async function processWebhookEvent(job: { data: { payload: any } }) {
const { payload } = job.data;
const eventId: string = payload.id; // Outstand's unique event ID
const eventType: string = payload.event; // e.g., 'post.published'
const outstandPostId: string = payload.data?.postId;
await db.transaction(async (trx) => {
// Dedup: insert event record; skip if already processed
const result = await trx.query(
`INSERT INTO webhook_events (event_id, event_type, payload)
VALUES ($1, $2, $3)
ON CONFLICT (event_id) DO NOTHING`,
[eventId, eventType, JSON.stringify(payload)]
);
if (result.rowCount === 0) {
console.log(`Duplicate webhook event ${eventId} — skipping`);
return;
}
if (eventType === 'post.published') {
await trx.query(
`UPDATE posts SET status = 'published'
WHERE outstand_post_id = $1 AND status != 'published'`, // idempotent guard
[outstandPostId]
);
} else if (eventType === 'post.error') {
const errorDetail = JSON.stringify(payload.data?.error);
await trx.query(
`UPDATE posts SET status = 'failed', error_detail = $2
WHERE outstand_post_id = $1 AND status != 'failed'`,
[outstandPostId, errorDetail]
);
}
});}
This skeleton is intentionally minimal. In production you'll add per-tenant account scoping, media upload handling via the upload media endpoint, comment threading via create a first comment, and analytics pulls via get post analytics.
Agency-facing benefits: why this architecture pays off
Before moving to the operational checklist, it's worth being explicit about why building on this architecture (rather than buying a generic white-label SaaS) gives agencies a real business advantage.
Brand control that goes all the way down
With BYO app credentials, clients see your registered OAuth app on every consent screen. Combined with your custom subdomain and UI, there's no visible third-party footprint. That's harder to achieve when you're reselling a SaaS product that controls the OAuth flow.
Pricing that scales with output, not seat count
Outstand charges $0.01 per post across 10+ platforms. For an agency managing 50 clients posting 20 times per week, that's $10/week in API costs, not $500/month in per-seat SaaS fees. Your margin scales with volume, not headcount.
Reliability you can contractually commit to
Outstand's infrastructure handles rate limits, token refreshes, and breaking API changes internally. Your SLA to clients is backed by uptime and latency characteristics you can verify: Outstand publishes 99.92% uptime and 180ms average response time. You add the reliability of your own scheduler layer on top.
Differentiated features competitors can't match
Because you own the product, you can build features a generic white-label SaaS doesn't offer: custom approval chains, per-client analytics dashboards via get account metrics, AI-assisted content generation wired directly to your post creation flow, or integrations with your clients' CRMs. Off-the-shelf tools lock you into their roadmap.
Operational checklist: launch your first 3 clients without surprises
Pre-launch testing
- **Reconnect flows:** Revoke a token manually in each platform's developer console and confirm your UI surfaces `needs_reauth` within one polling cycle. Confirm the reconnect flow creates no duplicate `social_accounts` rows.
- **Permission revocation:** Test what happens when a client reduces granted scopes mid-subscription. Confirm your scope-check catches the mismatch before the next dispatch attempt.
- **Rate limit simulation:** Send enough rapid requests to trigger Outstand's queue (you won't see a 429, but confirm posts dispatch in order and none are dropped).
- **DST edge cases:** Schedule posts for times that fall in the DST transition window (e.g., 2:00-3:00 AM on changeover day) and verify UTC conversion is correct.
- **Cancellation:** Cancel a scheduled post after it's been enqueued. Confirm the worker checks for `cancelled` status before calling Outstand and aborts gracefully.
- **Idempotency:** Kill the worker process mid-dispatch and restart it. Confirm the same `idempotency_key` prevents a duplicate post.
Observability instrumentation
Before onboarding any client, instrument these four metrics:
- Dispatch queue latency: Time from
scheduled_atto Outstand API call. Alert if > 5 minutes. - Webhook processing lag: Time from Outstand event
created_atto yourwebhook_events.processed_at. Alert if > 60 seconds. - Error rate by platform: Track
post.errorevents per platform per hour. Unusual spikes indicate platform API changes. - Per-tenant failure breakdown: A dashboard showing each tenant's published/failed ratio lets you proactively identify clients with degraded accounts before they file a support ticket.
Runbooks (write before you need them)
- Platform API change: When a new required field or breaking schema change arrives, Outstand absorbs most of it. But monitor Outstand's changelog and test non-default configurations (e.g., Instagram carousel posts, X long-form content) after platform announcements.
- Webhooks stop arriving: Check Outstand's status page first. If the service is healthy, verify your webhook endpoint URL is accessible from the internet (not blocked by firewall or returning 4xx on health checks). Replay recent events from the DLQ.
- Media processing failure: If
upload_mediacalls succeed but posts fail with media-related errors, check aspect ratio and file size requirements per platform. Refer to platform-specific configuration docs for per-platform media constraints.
Security and compliance readiness
For agencies approaching SOC 2 Type II or handling enterprise clients:
- Encryption at rest: All PII and OAuth tokens encrypted using AES-256-GCM before database write.
- Encryption in transit: TLS 1.2 minimum on all API endpoints and webhook receivers.
- RBAC audit: Verify that no endpoint can return another tenant's data even with a valid JWT. Run automated tests that attempt cross-tenant data access.
- Logging and retention: Log all authentication events, post dispatch attempts, approval actions, and webhook processing. Retain for minimum 90 days (or per your clients' contractual requirements).
- Incident response plan: Define detection, containment, notification, and recovery steps for credential exposure and unauthorized access before you go live, not after.
Choosing the right approach for your agency
If you're not ready to build the full scheduler architecture described above, the Outstand white-label social media management offering provides a faster path: BYO API keys, your branding, and unlimited client accounts without building the backend from scratch.
For teams that want to build, the unified social media API lets you integrate X, LinkedIn, Instagram, and 7 more platforms with a single API call. The social media analytics and scheduling guide covers the broader product architecture for teams building schedulers and analytics tools side-by-side.
If you're evaluating API options, the unified social media API pros and cons breakdown covers vendor lock-in, performance, and BYOK trade-offs in detail.
Frequently asked questions
Do clients need to know I'm using Outstand? No. With BYO app credentials, clients only see your registered OAuth app name and logo on the consent screen. Outstand has no visible presence in your product.
How does $0.01 per post pricing work at scale? You pay per successful post dispatch, not per connected account or per user. An agency posting 10,000 times per month across all clients pays $100. There are no minimum commitments.
What happens if Outstand has downtime? Outstand publishes 99.92% uptime. Your scheduler should treat a failed Outstand API response as a transient error and retry according to the policy described above. Posts remain in approved state and the worker retries on the next cycle.
Can I add platforms Outstand doesn't support? Yes. Because you own the product layer, you can add custom platform integrations directly alongside Outstand. Your social_accounts table can carry a provider field that routes dispatch to either Outstand or your custom connector.
How do I handle multi-image or video posts? Use the upload media endpoint to stage media assets before creating the post. Outstand handles platform-specific media optimization automatically. Use confirm upload to verify the asset is ready before referencing it in a post payload.