Zernio
Zernio
OverviewBuild a PlatformPublishingAnalyticsInbox and DMs
Dashboard
llms.txtOpenAPI
OverviewPlatformsAPI ReferenceResources

Build a Platform

Give each of your customers a profile, connect their accounts into it, post on their behalf and route webhooks back to the right customer.


When you finish this page each of your customers has a profile, their accounts are connected inside it, and every webhook lands on the right customer record. You need an API key. Give each profile your own customer id as its externalId and every webhook carries that id back, so routing an event needs no lookup on your side. One profile per customer is the whole model: a customer with 3 Facebook Pages and 2 Instagram accounts connects all 5 into their one profile. Profiles are free; each connected account is metered (pricing).

Publishing

Retry-safe posting for every customer: idempotency keys, media, per-profile queues

Analytics dashboards

Serve each customer their own metrics from a sync worker

Inbox and DMs

One inbox per customer, written by webhooks

Requests carry profileId in, and events carry your own externalId back on profiles[]:

Every new team starts with a profile named "Default". Keep it for your own accounts and create one profile per customer next to it.

Step 1: Create a profile per customer

Call POST /v1/profiles when a customer signs up, with a name for your dashboard and your own customer id as externalId. Store profile._id on the customer record for the API calls in the next steps; if you lose it, GET /v1/profiles?externalId=cus_8f3a2 returns the profile. externalId is unique among the profiles you own, so a retried signup cannot create a second profile for the same customer (see If it fails).

import Zernio from '@zernio/node';

const zernio = new Zernio();

const { data: created } = await zernio.profiles.createProfile({
  body: {
    name: 'Acme Corp',
    externalId: 'cus_8f3a2',
  },
});

const profileId = created.profile._id; // store on your customer record

Response (201):

{
  "message": "Profile created successfully",
  "profile": {
    "_id": "66a1f0c2a4b9d3e8f1a2b3c4",
    "name": "Acme Corp",
    "externalId": "cus_8f3a2",
    "isDefault": false,
    "createdAt": "2026-09-08T10:00:00Z"
  }
}

The Create profile endpoint lists every field.

Step 2: Connect their accounts

Call GET /v1/connect/{platform} with the customer's profileId so the account lands in their profile. Pass headless=true to draw the selection screens yourself instead of using the ones Zernio hosts.

const { data: connect } = await zernio.connect.getConnectUrl({
  path: { platform: 'instagram' },
  query: { profileId, redirect_url: 'https://your-app.com/callback' },
});
// Send the customer's browser to connect.authUrl

Response (200):

{
  "authUrl": "https://www.instagram.com/oauth/authorize?client_id=...",
  "state": "..."
}

The connecting accounts guide covers headless mode and the platforms that add a selection step (Facebook Pages, LinkedIn organizations, Pinterest boards). A customer who manages several Pages runs this flow once per Page, always with the same profileId.

Subscribe to account.connected to learn about new connections on your server. profiles[0].externalId names the customer and account.accountId is the new account:

{
  "event": "account.connected",
  "profiles": [{ "id": "66a1f0c2a4b9d3e8f1a2b3c4", "externalId": "cus_8f3a2" }],
  "account": {
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "profileId": "66a1f0c2a4b9d3e8f1a2b3c4",
    "platform": "instagram",
    "username": "acmecorp"
  }
}

account.disconnected carries the same identifiers when a token dies or a customer removes access.

The onboarding flow end to end:

Step 3: Post on their behalf

Call GET /v1/accounts with the customer's profileId, then POST /v1/posts with those account ids.

const { data: listed } = await zernio.accounts.listAccounts({
  query: { profileId },
});

const { data: published } = await zernio.posts.createPost({
  body: {
    content: 'Posted from your app.',
    platforms: listed.accounts.map((a) => ({
      platform: a.platform,
      accountId: a._id,
    })),
    publishNow: true,
  },
});

console.log(published.post.status);

Response (200) from the list:

{
  "accounts": [
    {
      "_id": "66b2e19d8c3f5a7e9d0b1c2d",
      "platform": "instagram",
      "username": "acmecorp",
      "isActive": true,
      "profileId": "66a1f0c2a4b9d3e8f1a2b3c4"
    }
  ]
}

Response (201) from the post:

{
  "message": "Post published successfully",
  "post": {
    "_id": "65f1c0a9e2b5af0012ab34cd",
    "status": "published",
    "platforms": [
      { "platform": "instagram", "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "status": "published" }
    ]
  }
}

POST /v1/posts accepts any accountId your team owns, whichever profile it sits in. Pass a customer only the account ids listed with their profileId.

Step 4: Route webhooks back to the right customer

Webhook endpoints are stored per team (up to 50). Run one endpoint and route by profiles[], or scope an endpoint to a set of profiles or accounts with profileIds / accountIds (next paragraph).

Every event that belongs to a profile carries a top-level profiles array with each profile's id and the externalId you set on it, so the customer is profiles[0].externalId:

{
  "id": "0b6f7a2e-4c1d-4e8a-9f3b-2d5c6e7f8a9b",
  "event": "message.received",
  "timestamp": "2026-09-08T10:05:00Z",
  "profiles": [{ "id": "66a1f0c2a4b9d3e8f1a2b3c4", "externalId": "cus_8f3a2" }],
  "account": { "accountId": "66b2e19d8c3f5a7e9d0b1c2d", "platform": "instagram" }
}

A post that targets accounts of several profiles lists each profile once; externalId is null on a profile you have not given one. Events that belong to no profile (verification, phone number stock, the API changelog, the test event) carry no profiles key. When you route per account instead, the webhooks overview lists where each event carries its account id.

To keep a set of customers on a separate endpoint instead, for example test tenants on staging, set profileIds (or accountIds) on that endpoint: it then only receives events from those profiles or accounts. The webhooks overview has the details.

The webhooks overview covers delivery, retries and signatures; the inbox guide has a full handler.

Step 5: Monitor account health

Tokens die when customers change passwords, revoke access or trip a platform security check. Build the reconnect loop before launch:

  1. Subscribe to account.disconnected. Its profiles[0].externalId is the customer to notify and account.accountId the account to reconnect.
  2. Prompt that customer to reconnect by starting a new connect flow with the same profileId.
  3. Call account health on a schedule to catch anything a webhook missed.
const { data: health } = await zernio.accounts.getAllAccountsHealth({
  query: { profileId, status: 'error' },
});

console.log(health.summary.needsReconnect);

Response (200):

{
  "summary": { "total": 1, "healthy": 0, "warning": 0, "error": 1, "needsReconnect": 1 },
  "accounts": [
    {
      "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
      "platform": "instagram",
      "username": "acmecorp",
      "status": "error",
      "canPost": false,
      "tokenValid": false,
      "needsReconnect": true,
      "issues": ["Token expired"]
    }
  ]
}

Rate limits with many customers

One API key covers every customer. Your rate limit grows with your team's connected accounts, and it is shared by all your customers, so queue bursts per customer (a bulk import, a mass reply) so one customer cannot starve the rest. Webhooks replace most polling. On a 429, wait for the Retry-After header before retrying.

Scoped API keys

An API key can reach every profile in your team by default. Call POST /v1/api-keys with scope: "profiles" and profileIds to create a key that sees only one customer's profile, and permission: "read" to make it read-only:

curl -X POST "https://zernio.com/api/v1/api-keys" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "acme-corp-readonly",
    "scope": "profiles",
    "profileIds": ["66a1f0c2a4b9d3e8f1a2b3c4"],
    "permission": "read"
  }'

Response (201):

{
  "message": "API key created successfully",
  "apiKey": {
    "id": "6507a1b2c3d4e5f6a7b8c9d0",
    "name": "acme-corp-readonly",
    "key": "sk_...",
    "scope": "profiles",
    "profileIds": ["66a1f0c2a4b9d3e8f1a2b3c4"],
    "permission": "read"
  }
}

Use scoped keys for a per-customer backend service, a read-only analytics dashboard, or temporary access with expiresIn (days). They are an access-control tool; the rate limit belongs to the team, so a scoped key adds no throughput.

To close off whole areas of the API rather than whole profiles, send disabledResourceGroups on the same call: a denylist over ten groups (publishing, engagement, messages, contacts, analytics, ads, telephony, accounts, billing, webhooks), so a per-customer backend that only schedules posts disables the other nine. A key with any group disabled mints with a zrk_ prefix instead of sk_, and refuses a call into a disabled group with 403, code: "insufficient_permissions" and the required_group it wanted. There is no update endpoint: to change the groups, create a second key and revoke the first. The Create API key endpoint lists every field.

What each customer costs you

Call GET /v1/usage?range=cycle&groupBy=profile for the current billing period's spend split per profile. attribution.groups[] carries one entry per profile id, and sum(groups) + unattributed equals totals exactly, so a customer's share of your invoice is a lookup on their profileId. Credits, 10DLC fees and Verify belong to no single profile and land in unattributed; a profile-scoped key sees only its own profiles' groups and says so with attribution.restricted: true. Billing has the rest of the metering contract.

Offboard a customer

  1. Disconnect their accounts. Active connected accounts block profile deletion with a 400.
  2. Delete the profile. Remaining disconnected accounts and provisioned WhatsApp numbers move to another of your profiles and are never deleted.

If it fails

A 409 from Step 1 means you already have a profile with that externalId (code: "profile_external_id_conflict") or with that name (code: "profile_name_conflict"):

{
  "error": "A profile with this externalId already exists",
  "code": "profile_external_id_conflict",
  "param": "externalId",
  "details": { "existingProfileId": "66a1f0c2a4b9d3e8f1a2b3c4" }
}

Use details.existingProfileId as the customer's profile instead of creating another. This is what makes a signup retry safe: a create that timed out on your side and is sent again gets the first profile back. A 402 with code: "PAYMENT_REQUIRED" is a billing gate on your team, so it affects every customer at once; send yourself to its dashboard_url rather than retrying.

Related

  • Publishing: retry-safe posting and per-profile queues.
  • Analytics dashboards: per-customer metrics from a sync worker.
  • Inbox and DMs: one inbox per customer, written by webhooks.
  • Connecting accounts: OAuth, headless mode and selection steps.
  • Webhooks: delivery, signatures and every event payload.
Was this page helpful?

Quickstart

Create an API key, connect an account and schedule your first post with 5 calls to the Zernio API.

Publishing

Publish for every customer with an idempotency key, one media upload per asset, each customer's own queue and outcomes from webhooks.

On this page

Step 1: Create a profile per customerStep 2: Connect their accountsStep 3: Post on their behalfStep 4: Route webhooks back to the right customerStep 5: Monitor account healthRate limits with many customersScoped API keysWhat each customer costs youOffboard a customerIf it failsRelated