SDK · Growth+

Custom OAuth providers

Register OAuth apps Leash hasn't pre-built — Slack, Notion, custom OIDC, your company's SSO, anything with a standard OAuth 2.0 flow. Apps in your org get a fresh access token through one platform call. The client_secret stays out of your app code.

1. Register the provider (once, on the dashboard)

On the org page, the “Custom OAuth providers” card walks you through:

  • A slug (used by your app to refer to it: slack, notion, …)
  • Display name and OAuth authorize / token URLs
  • Default scopes
  • Your client_id and client_secret — encrypted with your org's KMS key before storage

Reserved slugs (gmail, google_calendar, google_drive, …) map to built-in providers.

2. Send users through the OAuth flow

Same shape as built-in providers — redirect the browser to /api/integrations/connect/<slug> on leash.build. The platform handles the round-trip and stores the user's tokens.

app/connect/slack/route.ts
export async function GET(req: Request) {
const returnUrl = new URL('/settings', req.url).toString()
const target = new URL('https://leash.build/api/integrations/connect/slack')
target.searchParams.set('return_url', returnUrl)
return Response.redirect(target, 302)
}

See Connections for the general shape.

3. Get the user's access token in your app

Once the user has connected, every app in your org can pull a fresh access token via POST /api/integrations/token. Refresh-on-expiry happens transparently on the platform side — no per-app handler.

app/api/post-to-slack/route.ts
export async function POST(req: Request) {
const tokenRes = await fetch('https://leash.build/api/integrations/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.LEASH_API_KEY!,
cookie: req.headers.get('cookie') ?? '',
},
body: JSON.stringify({ provider: 'slack' }),
})
if (!tokenRes.ok) {
const { code } = await tokenRes.json()
// code === 'not_connected' → redirect to /api/integrations/connect/slack
return Response.json({ error: code }, { status: tokenRes.status })
}
const { data } = await tokenRes.json()
await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
Authorization: `Bearer ${data.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ channel: '#general', text: 'hello' }),
})
return Response.json({ ok: true })
}

The token endpoint is a plain HTTPS call — usable from any language. The TypeScript SDK is the reference implementation of the 0.4 Leash() client shape; Python, Go, Ruby, Java, and Rust SDKs are tracking the same shape and will wrap this call directly when those rebuilds land (tracked separately).

Failure modes

The token endpoint returns the legacy lowercase codes (not_connected, token_expired, upgrade_required) directly in the JSON body.

  • not_connected — the user hasn't authorized this provider yet. Redirect them to /api/integrations/connect/<slug>. The response also includes a ready-to-use connectUrl.
  • token_expired — refresh failed (user revoked access, or the upstream provider rotated something). Treat the same as not_connected.
  • upgrade_required — registering custom OAuth providers requires Growth+. Built-in providers (Gmail, Calendar, Drive) work on every plan.

When calling built-in providers through the typed Leash client, the same failures surface as a LeashError with the canonical INTEGRATION_NOT_ENABLED, UNAUTHORIZED, or UPGRADE_REQUIRED codes — see Error handling.