Docs

Build a platform

Serve many customers from one integration using profiles, scoped keys, and account-level webhooks.

If you are adding social publishing to your own product — an agency dashboard, a CRM, a scheduling tool, a vertical SaaS — you need one integration that serves many customers without their data ever meeting. That is what profiles are for.

A profile is one tenant. It owns its connected social accounts, its posts, its media, and its policy. Nothing crosses a profile boundary, ever, and there is no request that can span two of them.

Your CuteDyno account          one bill, one account-wide API key
├── Profile: Acme Corp         @acme on Instagram, @acme on TikTok
├── Profile: Globex            Globex LinkedIn page
└── Profile: Initech           Initech YouTube channel

The shape of the integration

Four decisions, and the rest follows:

  1. One profile per customer, created when they sign up in your product.
  2. One account-wide API key, held by your backend. Not one key per customer.
  3. Scope every call with profileId, taken from your own customer record.
  4. One webhook endpoint, routed by the profileId in the payload.

That is the whole model. Below is each piece.

1. Create a profile per customer

Do this in the same transaction that creates your own customer record, and store the returned ID next to it.

import { CuteDyno } from '@cutedyno/node';

const cutedyno = new CuteDyno(); // account-wide key from the environment

export async function onCustomerSignup(customer: Customer) {
  const { profile } = await cutedyno.profiles.create(
    {
      name: customer.companyName,
      description: `Tenant ${customer.id}`,
    },
    { idempotencyKey: `profile-${customer.id}` }
  );

  await db.customers.update(customer.id, { cutedynoProfileId: profile.id });
}

The idempotency key matters here. Signup flows get retried, and without one a double-submit leaves you with two profiles and a customer who can only see half their accounts. The SDK generates a key per call automatically, which covers a retry inside one call, but deriving the key from your own customer ID also covers a retry from a different process or a later job run. See idempotency.

2. Use one key, not one per customer

It is tempting to mint a key per customer. Resist it: you gain nothing, and you inherit the job of storing, rotating, and revoking thousands of secrets.

Instead, hold one account-wide key — one created without profileIds — in your backend's secret manager, and name the profile on each call.

const { accounts } = await cutedyno.accounts.list({
  profileId: customer.cutedynoProfileId,
});

Every profile-scoped endpoint takes profileId, as a query parameter on reads and a body field on writes. The SDK will also carry a default for you:

const forCustomer = new CuteDyno({ profileId: customer.cutedynoProfileId });
const { accounts } = await forCustomer.accounts.list(); // implicitly scoped

There is one case where per-customer keys are right: when the customer's own code, or an agent acting for them, calls CuteDyno directly. Then create a key with profileIds: [theirProfile] so it cannot see anything else, and note that such keys cannot mint further keys. Authentication covers the scoping rules.

3. Let customers connect their own accounts

Never ask a customer for their social password, and never proxy their OAuth through your own app credentials. Create a connect session and redirect.

app.post('/settings/social/connect', async (req, res) => {
  const { authUrl } = await cutedyno.accounts.connect({
    platform: req.body.platform,
    profileId: req.user.cutedynoProfileId,
    redirectUrl: `https://yourapp.com/settings/social?connected=${req.body.platform}`,
  });

  res.redirect(authUrl);
});

The user authorises on the platform, CuteDyno stores and refreshes the tokens, and they land back on your redirectUrl. The account is then attached to that profile and nothing else. Connecting accounts covers failure cases and re-authorisation.

4. One webhook endpoint for everyone

Register a single account-scoped subscription and route by profile. Do not register one webhook per profile — you would then have thousands of endpoints delivering to the same URL.

await cutedyno.webhooks.create({
  url: 'https://yourapp.com/webhooks/cutedyno',
  scope: 'account',
  events: ['post.published', 'post.failed', 'account.disconnected'],
});

Every payload carries profileId, so the receiver is a lookup away from your own customer:

import express from 'express';
import { verifyWebhook } from '@cutedyno/node';

app.post(
  '/webhooks/cutedyno',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const event = verifyWebhook<{ profileId: string }>({
      payload: req.body,
      signature: req.header('CuteDyno-Signature'),
      secret: process.env.CUTEDYNO_WEBHOOK_SECRET!,
    });

    const customer = await db.customers.findByProfileId(event.data.profileId);
    // A profile we no longer track. Acknowledge so it is not retried for a day.
    if (!customer) return res.sendStatus(200);

    res.sendStatus(200);
    await enqueue(customer, event);
  }
);

Verify the signature before you trust profileId — it is the field that decides which customer's data you are about to write. Webhooks has the full verification recipe.

Guardrails per customer

Each profile has its own policy, so one customer can require approval while another publishes freely:

await cutedyno.policy.update({
  profileId: customer.cutedynoProfileId,
  requireApproval: true,
  allowedPlatforms: ['instagram', 'tiktok'],
  maxPostsPerDay: 5,
});

Posts that hit a policy land in pending_approval and fire approval.requested rather than failing. Agent governance covers approvals, and matters most when the thing creating posts is an LLM.

Offboarding

When a customer leaves, disconnect their social accounts first, then delete the profile:

await cutedyno.profiles.del(customer.cutedynoProfileId);

Deleting a profile that still has connected accounts fails with profile_has_connected_accounts. That is deliberate: revoking a customer's platform tokens should be an explicit act, not a side effect of a cleanup job.

A checklist before you ship

  • Profile IDs stored against your own customer records, not derived at request time.
  • One account-wide key in a secret manager, with lastUsedAt monitored.
  • An idempotency key on every profile creation and post creation.
  • One account-scoped webhook, signature verified, profileId used for routing.
  • 429 handled by honouring Retry-After. See rate limits.
  • partially_published handled — some accounts succeed while others fail, and this is normal.

Recipes

  • Publishing — scheduling, per-platform variants, and retries.
  • Reporting — reading delivery results back into your own reporting.
  • Agent governance — letting an LLM post without letting it post anything.