How to Build a White-Label Social Media Scheduling Tool for Agencies (2026)

A developer blueprint for building a multi-tenant white-label social scheduler on Outstand's unified API: BYO OAuth, scheduling queues, webhook handling, and RBAC.

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:

  1. Branded UX - your domain, your colors, your login screen. No third-party branding anywhere a client can see.
  2. Tenant isolation - every client's posts, credentials, and analytics are strictly partitioned from every other client's data.
  3. Client-connected social credentials - each client authenticates their own social accounts, and those tokens are stored and refreshed under your control.
  4. 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 POST /v1/posts across X, LinkedIn, Instagram, and 7 more

Tenant/workspace data model

scheduledAt field for future publishing

Approval workflow and state machine

Signed, retried, ordered webhooks (post.published, post.error)

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_id column + row-level security (RLS): Simplest to operate, lowest cost. Every query must be scoped by tenant_id. PostgreSQL RLS policies can enforce this at the database layer, but you must set the app.current_tenant session 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

  1. Create the tenant record - provision a row in your tenants table, assign a subdomain, store brand settings.
  2. Collect brand and approval settings - logo URL, primary color, approval_required flag, default platforms.
  3. Store BYO credentials - for each platform your client needs, store the clientId/clientSecret of your registered OAuth app encrypted at rest (see token management below).
  4. 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.
  5. Handle the OAuth callback - exchange the code, confirm the connection via Outstand's connect a new social network endpoint, and persist the returned socialAccountId in your social_accounts table.
  6. Verify scopes and permissions - call get details of a social network and confirm the required scopes are granted.
  7. Mark the account as active - update social_accounts.status and 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 by state parameter, 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 duplicate social_accounts row.
  • 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:

  1. Acquire a lock with a TTL (e.g., 30 seconds) from Redis or your distributed lock service.
  2. 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.
  3. 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):

  1. Set social_accounts.status = 'needs_reauth'.
  2. Cancel or hold all scheduled posts for that social account (set to a held sub-status rather than deleting).
  3. Notify the tenant's client_admin and the agency_admin via email/in-app notification.
  4. 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:

  1. Poll or subscribe to a queue for dispatchable posts (e.g., a sorted set in Redis keyed by scheduled_at, or a cron that queries WHERE status = 'approved' AND scheduled_at <= now()).
  2. For each dispatchable post, set status to publishing before calling Outstand. This prevents concurrent workers from picking up the same post.
  3. Call POST /v1/posts on Outstand with the content, the tenant-scoped socialAccountIds, and the scheduledAt field. Outstand handles the fan-out to multiple platforms in a single request.
  4. Persist the returned outstand_post_id to your posts table for webhook correlation.
  5. 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 failed and 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

post.published

publishingpublished

post.error

publishingfailed (log error detail)

Social account event (permission revoked)

Update social_accounts.status to needs_reauth

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_id after 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

agency_admin

Full access across all tenants; can override approvals

team_member

Create and edit posts; submit for approval; view all client tenants they're assigned to

client_admin

Approve/reject posts; connect social accounts; view analytics for their tenant

client_viewer

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_admin notified on new submission; team_member notified 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:

  1. Dispatch queue latency: Time from scheduled_at to Outstand API call. Alert if > 5 minutes.
  2. Webhook processing lag: Time from Outstand event created_at to your webhook_events.processed_at. Alert if > 60 seconds.
  3. Error rate by platform: Track post.error events per platform per hour. Unusual spikes indicate platform API changes.
  4. 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_media calls 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.