Documentation

OAuth flow

The universal vendor connect flow — request URL, consent, callback, and token storage.

Every OAuth-based vendor integration in AppEngine follows the same shape: the org admin clicks "Connect Stripe" (or Facebook, Google, etc.), the platform redirects through the vendor's consent screen, the vendor returns a code to AppEngine's callback, and AppEngine exchanges it for an access token that gets encrypted and stored against the org. Once stored, Upstream calls the vendor on the org's behalf.

This page walks through the universal flow. Vendor-specific quirks live in the per-vendor guides.

The four steps

  1. 1

    Get the auth URL

    The admin UI calls an integration-type-specific endpoint that builds the consent URL with the right scopes and a state token tied to the org and the return URL.

    const { authUrl, state } = await fetch(
      '/api/upstream/call/facebook-provider/get-auth-url',
      {
        method: 'POST',
        headers: { orgid: ORG_ID, Authorization: `Bearer ${jwt}` },
        body: JSON.stringify({
          scopes: ['pages_manage_posts', 'pages_read_engagement'],
          returnUrl: 'https://admin.example/integrations',
        }),
      },
    ).then(r => r.json());
    

    The state is signed with the org ID and a nonce. The platform validates it on callback to prevent CSRF.

  2. 2

    Redirect to the vendor

    The browser navigates to authUrl. The vendor shows their consent screen — "AppMint wants access to your Facebook pages, allow?".

    window.location.href = authUrl;
    
  3. 3

    Vendor calls back to AppEngine

    The vendor redirects the user back to:

    GET/connect/oauth2callback/:vendorNo auth

    with code and state query params. The Connect module:

    1. Validates the state against the signed token, extracts the org ID.
    2. Calls the vendor's token-exchange endpoint with the code to get an access_token and refresh_token.
    3. Encrypts the tokens and saves them to the integration record for the org.
    4. Redirects the browser to the original returnUrl with ?integration=connected.

    This route is @PublicRoute() because the vendor cannot send a JWT.

  4. 4

    Use the integration

    Subsequent Upstream calls find the saved tokens by orgid, refresh them automatically when they expire, and pass the active token in the vendor SDK call.

    // Now this works without any further auth
    await fetch('/api/upstream/call/facebook-provider/post-to-page', {
      method: 'POST',
      headers: { orgid: ORG_ID, Authorization: `Bearer ${jwt}` },
      body: JSON.stringify({ pageId: '...', message: 'Hello' }),
    });
    

Refresh tokens

When a token expires, the vendor connector implementation calls the refresh endpoint and rotates the stored token in-place. Application code never sees this — the call that triggered the refresh waits for the new token and retries once.

If a refresh fails (revoked grant, expired refresh token), the integration moves to status: "expired". The next call returns a 412-style error and the admin must reconnect via step 1.

State and CSRF protection

The state parameter is a signed JWT containing:

  • orgId — which org is connecting
  • userId — which user clicked connect
  • returnUrl — where to send the browser back
  • nonce — single-use, validated and burned on callback

If a state token is replayed, malformed, or older than 10 minutes, the callback rejects with 400.

Multi-account vendors

For vendors where one org connects multiple accounts (e.g. Facebook with multiple ad accounts), the integration record stores an array of accounts. The Upstream call accepts an accountId parameter to pick which one to use.

await fetch('/api/upstream/call/facebook-provider/post-to-page', {
  method: 'POST',
  headers: { orgid: ORG_ID, Authorization: `Bearer ${jwt}` },
  body: JSON.stringify({
    accountId: 'act_123',
    pageId: '...',
    message: 'Hello',
  }),
});

Disconnecting

POST/upstream/shutdown/:idJWT

shutdown revokes the token where the vendor supports it (Stripe, Facebook do; some don't), zeroes the credential blob, and marks the integration inactive. Any in-flight calls fail; subsequent calls return "vendor not configured".

Error handling at consent

If the user denies consent, the vendor redirects back with error=access_denied. The Connect module logs the failure, marks the in-progress integration failed, and redirects the browser back to returnUrl with ?integration=denied. The admin UI surfaces the error.

Vendors without OAuth

A few vendors (Mailgun, SendGrid, AWS SES, Twilio API-key mode) authenticate via long-lived API keys rather than OAuth. The flow shortens to a single form: paste the key, click test, save.

await fetch('/api/upstream/save-integration', {
  method: 'POST',
  headers: { orgid: ORG_ID, Authorization: `Bearer ${jwt}` },
  body: JSON.stringify({
    type: 'sendgrid-provider',
    name: 'Production SendGrid',
    credentials: {
      apiKey: 'SG.xxx',
    },
  }),
});

// then test
await fetch('/api/upstream/test/sendgrid-provider/verify', {
  method: 'POST',
  headers: { orgid: ORG_ID, Authorization: `Bearer ${jwt}` },
  body: JSON.stringify({}),
});

The same encryption-at-rest applies. The Upstream call shape doesn't change between OAuth and API-key vendors — application code is identical either way.

The unified automation webhook at /connect/automation/:id/:stepId/:entity/:activity/ is what tracking pixels and click-redirects target. It's separate from vendor OAuth callbacks but uses the same Connect module.