> ## Documentation Index
> Fetch the complete documentation index at: https://docs.postsyncer.com/llms.txt
> Use this file to discover all available pages before exploring further.

# OAuth Delegation

> Let your app connect a user’s social account through PostSyncer and receive verified ownership proof

# OAuth Delegation

OAuth delegation lets a third-party app connect a user's social media account
**through PostSyncer** and receive back a **signed proof of ownership** for that
account. It works for any platform PostSyncer supports that uses OAuth
(TikTok, Instagram, X/Twitter, LinkedIn, YouTube, Facebook, Pinterest, Threads).

Instead of integrating each platform's OAuth yourself, you redirect the user to
PostSyncer, PostSyncer runs the platform OAuth, and then redirects the user back
to you with the platform's permanent user id, the handle, and an
**HMAC-SHA256 signature** you can verify with your signing secret.

<Note>
  Delegation does not store a social account on your PostSyncer workspace. The
  `platform_id` and `handle` are read directly from the platform's OAuth response
  and passed through to you - nothing else is persisted.
</Note>

<Warning>
  Your **API key is a full-access secret** and must never appear in the browser.
  You start delegation with a **server-to-server** call from your backend (the API
  key goes in the `Authorization` header), and PostSyncer returns an opaque,
  single-use `authorize_url`. Only that opaque URL is ever sent to the user's
  browser.
</Warning>

## Prerequisites

1. A PostSyncer **API key** ([create one](https://app.postsyncer.com/dashboard?action=settings\&section=api-integrations)).
2. A **signing secret** for that API key. In **Settings → API Keys**, click the
   shield icon on the API key row and choose **Generate signing secret**. Copy
   it - it's shown only once. Regenerating invalidates the previous secret.

<Warning>
  Keep the signing secret private. It is the shared secret used to sign and verify
  ownership proofs. Anyone with it can forge proofs.
</Warning>

## Flow overview

```text theme={null}
Your server ─(1) POST /oauth/delegate/sessions (api_key in header)─▶ PostSyncer
Your server ◀──────────── { authorize_url } (opaque, single-use) ───
   │
   (2) redirect the user's browser to authorize_url
                               │
                               PostSyncer runs platform OAuth (TikTok, X, …)
                               │
Your app ◀─(3) redirect────── PostSyncer  (callback_url + signed params)
   │
   (4) verify state, expires, sig  ──▶ trust platform_id as ownership proof
```

## Step 1 - Create a delegation session (server-to-server)

From **your backend**, call:

```text theme={null}
POST https://postsyncer.com/api/oauth/delegate/sessions
```

with your API key in the `Authorization` header:

<ParamField header="Authorization" type="string" required>
  `Bearer <your PostSyncer API key>`. Sent server-to-server only - never exposed
  to the browser.
</ParamField>

<ParamField body="platform" type="string" required>
  The platform to connect, e.g. `tiktok`, `instagram`, `twitter`, `linkedin`,
  `youtube`, `facebook`, `pinterest`, `threads`.
</ParamField>

<ParamField body="callback_url" type="string" required>
  Absolute URL PostSyncer redirects the user back to after authentication.
</ParamField>

<ParamField body="state" type="string" required>
  A random, single-use string you generate. Used for CSRF protection; PostSyncer
  echoes it back unchanged.
</ParamField>

The response contains an **opaque, single-use** authorize URL (valid for 15 minutes):

```json theme={null}
{
  "authorize_url": "https://app.postsyncer.com/oauth/delegate?request=psd_xxx",
  "expires_in": 900
}
```

```javascript Node.js theme={null}
import crypto from 'crypto';

const state = crypto.randomBytes(16).toString('hex');
// persist `state` server-side (single use) so you can verify it on return

const res = await fetch('https://postsyncer.com/api/oauth/delegate/sessions', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.POSTSYNCER_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    platform: 'tiktok',
    callback_url: 'https://yourapp.com/postsyncer/callback',
    state,
  }),
});

const { authorize_url } = await res.json();
```

## Step 2 - Redirect the user to the authorize URL

Send the user's browser to the `authorize_url` you received. It carries only an
opaque request token - no API key, platform or callback URL.

```javascript Node.js theme={null}
res.redirect(authorize_url);
```

PostSyncer then runs the platform's normal OAuth consent screen.

## Step 3 - PostSyncer redirects back to your `callback_url`

On success, PostSyncer redirects the user to your `callback_url` with:

<ResponseField name="platform" type="string">
  The platform that was connected.
</ResponseField>

<ResponseField name="platform_id" type="string">
  The platform's permanent unique identifier for the user (e.g. TikTok `open_id`,
  X `user_id`, Instagram `user_id`). This is the value you trust as ownership proof.
</ResponseField>

<ResponseField name="handle" type="string">
  The username on that platform. For display only - handles can change.
</ResponseField>

<ResponseField name="state" type="string">
  The same value you sent in step 1.
</ResponseField>

<ResponseField name="expires" type="integer">
  Unix timestamp, 5 minutes from when the proof was issued.
</ResponseField>

<ResponseField name="sig" type="string">
  `HMAC-SHA256` signature of the string
  `platform={platform}&platform_id={platform_id}&handle={handle}&state={state}&expires={expires}`
  using your signing secret.
</ResponseField>

```text Example callback theme={null}
https://yourapp.com/postsyncer/callback
  ?platform=tiktok
  &platform_id=_000abc123
  &handle=janedoe
  &state=9f2b...c4
  &expires=1717000000
  &sig=4d6c...e1
```

If something fails (or the user cancels), PostSyncer redirects to your
`callback_url` with `error`, `error_description`, and `state` instead.

## Step 4 - Verify the proof

On your server, before trusting `platform_id`:

1. **Verify `state`** matches the value you generated and mark it as used (single use).
2. **Verify `sig`** by recomputing the HMAC over the exact base string.
3. **Verify `expires`** is in the future.

Only if all three pass should you treat `platform_id` as verified ownership.

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from 'crypto';

  function verifyDelegation(query, signingSecret, expectedState) {
    const { platform, platform_id, handle, state, expires, sig } = query;

    // 1. CSRF - must match what you issued, and be single-use
    if (!state || state !== expectedState) return false;

    // 3. Freshness
    if (!expires || Number(expires) < Math.floor(Date.now() / 1000)) return false;

    // 2. Signature
    const base =
      `platform=${platform}&platform_id=${platform_id}` +
      `&handle=${handle}&state=${state}&expires=${expires}`;
    const expected = crypto
      .createHmac('sha256', signingSecret)
      .update(base)
      .digest('hex');

    return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  }
  ```

  ```php PHP theme={null}
  <?php

  function verifyDelegation(array $query, string $signingSecret, string $expectedState): bool
  {
      $platform   = $query['platform'] ?? '';
      $platformId = $query['platform_id'] ?? '';
      $handle     = $query['handle'] ?? '';
      $state      = $query['state'] ?? '';
      $expires    = $query['expires'] ?? '';
      $sig        = $query['sig'] ?? '';

      // 1. CSRF - must match what you issued, and be single-use
      if ($state === '' || ! hash_equals($expectedState, $state)) {
          return false;
      }

      // 3. Freshness
      if ((int) $expires < time()) {
          return false;
      }

      // 2. Signature
      $base = "platform={$platform}&platform_id={$platformId}&handle={$handle}&state={$state}&expires={$expires}";
      $expected = hash_hmac('sha256', $base, $signingSecret);

      return hash_equals($expected, $sig);
  }
  ```

  ```python Python theme={null}
  import hmac, hashlib, time

  def verify_delegation(query, signing_secret, expected_state):
      platform    = query.get("platform", "")
      platform_id = query.get("platform_id", "")
      handle      = query.get("handle", "")
      state       = query.get("state", "")
      expires     = query.get("expires", "")
      sig         = query.get("sig", "")

      # 1. CSRF - must match what you issued, and be single-use
      if not state or state != expected_state:
          return False

      # 3. Freshness
      if int(expires or 0) < int(time.time()):
          return False

      # 2. Signature
      base = f"platform={platform}&platform_id={platform_id}&handle={handle}&state={state}&expires={expires}"
      expected = hmac.new(signing_secret.encode(), base.encode(), hashlib.sha256).hexdigest()

      return hmac.compare_digest(expected, sig)
  ```
</CodeGroup>

## Errors

**Step 1 (session creation)** returns a JSON error to your server with an HTTP
4xx status and a `code`:

<ResponseField name="missing_api_key" type="error">
  No API key was provided in the `Authorization` header. (401)
</ResponseField>

<ResponseField name="invalid_api_key" type="error">
  The API key is invalid or expired. (401)
</ResponseField>

<ResponseField name="no_signing_secret" type="error">
  The API key has no signing secret. Generate one under **Settings → API Keys**. (422)
</ResponseField>

<ResponseField name="unsupported_platform" type="error">
  The `platform` value is not a supported OAuth platform. (422)
</ResponseField>

**Step 3 (after the user authorizes)** errors are redirected back to your
`callback_url` with an `error`, `error_description` and `state` instead of a
signed proof:

<ResponseField name="access_denied" type="error">
  The user cancelled or declined the platform consent screen.
</ResponseField>

<ResponseField name="connection_failed" type="error">
  The platform OAuth did not complete.
</ResponseField>

<ResponseField name="expired_request" type="error">
  The authorize URL was already used or expired before the user opened it.
</ResponseField>

<Note>
  The signature only covers successful proofs. Always treat any response that
  carries an `error` parameter as a failed connection.
</Note>
