<!-- https://cutedyno.com/docs/build-a-platform -->

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

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

```typescript
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](/docs/guides/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.

```typescript
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:

```typescript
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](/docs/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.

```typescript
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](/docs/guides/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.

```typescript
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:

```typescript
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](/docs/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:

```typescript
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](/docs/guides/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:

```typescript
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](/docs/guides/rate-limits).
- `partially_published` handled — some accounts succeed while others fail, and this is normal.

## Recipes

- [Publishing](/docs/publishing) — scheduling, per-platform variants, and retries.
- [Reporting](/docs/guides/reporting) — reading delivery results back into your own reporting.
- [Agent governance](/docs/guides/agent-governance) — letting an LLM post without letting it post anything.
