Aha! Builder applications can call the APIs of the services your team already uses. Connections is a managed OAuth 2.0 client: you name a provider, and your application gets an authorized connection to it — no client ID, client secret, redirect URI, token store, or refresh logic to wire up. Elle adds one when you describe an application that needs to read from or write to another service.
This reference documents the connections API: authenticate, isAuthenticated, request, and the two connection errors your code must tell apart. Read it to understand how your application reaches other services, to review what Elle generated, or to ground an AI assistant in the specifics — reference this article with Elle or any LLM so it knows exactly how connections work in Aha! Builder.
Click any of the following links to skip ahead:
Overview
Connections is a managed OAuth 2.0 client. Name a provider and the package runs the OAuth flow for you. If you have used OAuth before, the three calls map onto concepts you already know:
Call |
OAuth equivalent |
Runs in |
|---|---|---|
|
Authorization Code grant. Send the user to the provider's consent screen, then exchange the code for tokens |
Client (browser) |
|
Is there a valid or refreshable access token for this provider |
Client or server |
|
Call the provider's resource server with the access token as a bearer credential |
Server functions |
The package owns everything between those calls: it exchanges the code for tokens, stores the access and refresh tokens, refreshes them when they expire, and attaches them to each request. Your code never sees, stores, or forwards a token.
Call authenticate before request. A request made without a valid token never reaches the provider — request throws NotAuthenticatedError. If an administrator has removed the integration entirely, request throws NotConnectedError instead.
Throughout this article a service is an OAuth provider the package supports, named by its key. The examples use slack; the same three calls work for any service.
The OAuth flow
Client code calls
connections.isAuthenticated('slack')to decide whether the user needs to authorize.If no valid token exists, client code calls
connections.authenticate('slack').The provider's consent screen opens in the browser, and the user approves the requested scopes.
The package exchanges the authorization code for an access token and a refresh token, stores both, and resolves the promise.
Server functions call
connections.request('slack', { ... }). The package attaches the access token and calls the provider, refreshing the token first if it has expired.
Note: The package stores tokens per environment. Authorizing in preview does not authorize production, so run the flow in each environment you use.
connections.authenticate (client)
function authenticate(serviceName: string): Promise<void>;Runs the OAuth Authorization Code grant for serviceName. This is the front-channel step, so call it from a component or event handler in the browser — never from a server function.
If no valid token exists, the provider's consent screen opens. The promise resolves once the user approves and the package has stored the tokens.
If a valid token already exists, the promise resolves immediately.
authenticateis idempotent.If the user dismisses or denies consent, the promise rejects — the equivalent of an
access_deniedresponse. Treat it as "still not authorized" and offer a retry.
The package requests the scopes the provider requires. You do not pass them.
import { connections } from '@aha-app/builder-core';
function ConnectSlackButton() {
async function connect() {
// Opens the provider's consent screen and resolves once tokens are stored.
await connections.authenticate('slack');
}
return <button onClick={connect}>Connect Slack</button>;
}Use isAuthenticated in client code to decide whether to show a connect button before the first authenticated action.
async function handleConnect() {
if (!(await connections.isAuthenticated('slack'))) {
await connections.authenticate('slack');
}
}Do not use isAuthenticated as a server-side guard immediately before connections.request. It returns only a boolean, so it cannot tell "needs authorization" apart from "an administrator removed the integration." Call connections.request and let it throw. Never wrap the call in a swallowing try/catch that hides the failure: either let the error propagate, or catch it deliberately to return a clear message to the client.
Per-app and per-user authorization
An administrator chooses one of two modes when adding a service to your application:
Per app: One authorization covers everyone who uses the application, as a client-level grant.
Per user: Each user runs their own authorization grant, and requests run with that user's tokens. Every user must complete
authenticatebefore their first request.
Note: A request from a user who has not authorized yet throws NotAuthenticatedError, not NotConnectedError — the integration is fully set up, and only this user's token is missing. Switching a service from per-app to per-user leaves the integration in place, so users who now lack a personal token get NotAuthenticatedError until each of them runs authenticate.
connections.isAuthenticated (client or server)
function isAuthenticated(serviceName: string): Promise<boolean>;Reports whether a usable access token exists for serviceName right now. Use it for client UI. It is not a substitute for handling connections.request errors.
Returns
truewhen the package holds a usable token for the service in the current environment. For a per-user service this means the current user is authorized; for a per-app service it means the application is authorized. An expired access token still counts as authenticated when a refresh token exists — the package renews it on the next request.Returns
falsewhen no token exists, the provider revoked it, it expired with no way to refresh it, or an administrator removed the integration.Never throws for an unauthorized service.
falseis the normal answer.
In client code, gate the interface between a connect button and the feature itself:
import { connections } from '@aha-app/builder-core';
import { useEffect, useState } from 'react';
function SlackPanel() {
const [connected, setConnected] = useState(false);
useEffect(() => {
connections.isAuthenticated('slack').then(setConnected);
}, []);
if (!connected) {
return (
<button
onClick={async () => {
await connections.authenticate('slack');
setConnected(true);
}}
>
Connect Slack
</button>
);
}
return <SendSlackMessageForm />;
}In a server function, call request and let connection failures throw. Do not preflight with isAuthenticated there, because that masks a removed integration as generic unauthenticated state. The simplest correct code lets NotAuthenticatedError and NotConnectedError propagate. Catch them only when you want to return a friendlier message, and never catch and ignore.
import {
server,
connections,
NotAuthenticatedError,
NotConnectedError,
} from '@aha-app/builder-core';
server.data('postSlackMessage', async ({ text }) => {
try {
const response = await connections.request('slack', {
method: 'POST',
path: '/api/chat.postMessage',
body: { channel: '#general', text },
});
return await response.json();
} catch (e) {
if (e instanceof NotConnectedError) {
return { error: 'not_connected', service: e.service };
}
if (e instanceof NotAuthenticatedError) {
return { error: 'not_authenticated', service: e.service };
}
throw e;
}
});Required error handling pattern
Follow these rules whenever your application code calls connections.request:
connections.requestthrows on connection failure. Letting the error propagate is the correct default, because it surfaces to the client automatically.Catch
NotConnectedErrorwhen you want a tailored message. Tell the user that an administrator must re-add the integration. Do not callconnections.authenticate.Catch
NotAuthenticatedErrorwhen you want a tailored message. Tell the user to connect the service, and runconnections.authenticatefrom client code.Never swallow these errors. Do not wrap
connections.requestin atry/catchthat only logs throughcaptureErroror returns success, because that hides a broken integration.Re-throw anything that is not a typed connection error. A
catchthat inspectsNotConnectedErrorandNotAuthenticatedErrormust end withthrow e, so unexpected failures still reject the call.For an optional side effect, catch the typed error and return a structured warning alongside the main result, so the client can show the right message.
import {
server,
connections,
NotAuthenticatedError,
NotConnectedError,
} from '@aha-app/builder-core';
server.data('completeTodo', async ({ id }) => {
const todo = await markTodoComplete(id);
try {
await connections.request('slack', {
method: 'POST',
path: '/api/chat.postMessage',
body: { channel: '#social', text: `Completed: ${todo.title}` },
});
} catch (e) {
if (e instanceof NotConnectedError) {
return {
todo,
warning: {
type: 'integration_removed',
service: e.service,
message:
'Slack is no longer connected. Ask an admin to re-add the integration.',
},
};
}
if (e instanceof NotAuthenticatedError) {
return {
todo,
warning: {
type: 'authorization_required',
service: e.service,
message: 'Connect Slack before sending notifications.',
},
};
}
throw e;
}
return { todo };
});Client code renders warning.message directly. The server function has already tailored it for each case, so the client does not branch on warning.type. Keep type in the payload only when the interface needs to react differently, such as rendering a connect button for authorization_required. Otherwise a single check surfaces every warning, including future ones.
The server function re-throws any error it did not turn into a warning, so surface that in a generic catch rather than letting it fall through silently:
try {
const result = await completeTodo({ id });
if (result.warning) {
toast.error(result.warning.message);
}
} catch (e) {
// Any other failure the server function re-threw. Surface it — do not swallow.
toast.error('Something went wrong. Please try again.');
}connections.request (server functions only)
function request(
serviceName: string,
request: ConnectionRequest
): Promise<Response>;Calls the provider's API with the access token attached as a bearer credential — the OAuth resource-server request. Available in server functions only. The package supplies the credential and refreshes it when needed, so you provide only the request:
interface ConnectionRequest {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
path: string;
query?: Record<string, string | number | boolean>;
headers?: Record<string, string>;
body?: unknown;
}On success you get back a standard Response object. Read it with response.json(), response.ok, response.status, and the rest of the Web API Response interface. The provider's own responses come back the same way, including its own 401 and 404. The package's own connection failures do not come back as a Response: they throw NotAuthenticatedError, NotConnectedError, or a generic ConnectionError.
import { server, connections } from '@aha-app/builder-core';
server.data('postSlackMessage', async ({ text }) => {
const response = await connections.request('slack', {
method: 'POST',
path: '/api/chat.postMessage',
body: { channel: '#general', text },
});
return await response.json();
});Request rules
Server functions only. Do not import
connectionsfor requests in client components.Start
pathwith a slash. It is a path on the provider's API, not a full URL. Usequeryfor query parameters andbodyfor the JSON body.Do not set the
Authorizationheader, orCookie,Host, orX-Api-Key. The package owns the bearer credential and rejects these headers.Use a direct
fetchfor secrets you manage yourself. Read them fromprocess.env.SECRET_NAME. Connections covers OAuth services only.
Error handling
connections.request throws for the package's own connection failures, which never reach the provider. Two of them are named, and they carry different remedies:
Error |
What it means |
Who fixes it, and how |
|---|---|---|
|
The integration is set up, but no usable token exists for this caller. The user (per-user) or the application (per-app) has not authorized yet, or the provider revoked the token, or it expired beyond refresh. |
The user runs |
|
The integration itself is gone. An administrator removed it, or nobody ever added it to the application. There is nothing to authorize against. |
An administrator re-adds the integration. |
Any other package-level failure throws the base ConnectionError rather than passing silently. Both named errors extend ConnectionError, so catching the base catches all three, and every one carries the offending service as .service. The package does not convert the provider's own 401 or 404, which keeps a rejected call distinct from a connection failure.
Do not collapse the two named errors into one message. The most common bug is reporting "not connected" when the real error is NotAuthenticatedError. Watch for these traps:
Vocabulary: "The user has not connected their Slack account yet" is
NotAuthenticatedError, notNotConnectedError.NotConnectedErrorcovers only an absent administrator-level integration, never a user who simply needs to authorize.Per-user services: A per-user service is fully connected the moment an administrator adds it, but every user still starts out unauthorized, so their first request throws
NotAuthenticatedError. Telling them to ask an administrator to re-add the integration is wrong. Point them at the connect button that runsconnections.authenticate.Branch on the specific class, not the base: Check
instanceof NotAuthenticatedErrorandinstanceof NotConnectedErrorseparately. Do not catchConnectionErrorand default to one case. If you do catch the base, branch one.namebefore choosing a message.
import { server, connections, ConnectionError } from '@aha-app/builder-core';
server.data('postSlackMessage', async ({ text }) => {
try {
const response = await connections.request('slack', {
method: 'POST',
path: '/api/chat.postMessage',
body: { channel: '#general', text },
});
return await response.json();
} catch (e) {
if (e instanceof ConnectionError) {
// e is NotConnectedError or NotAuthenticatedError; e.service is 'slack'.
return { error: e.name, service: e.service };
}
throw e;
}
});Unauthenticated requests
When you call connections.request for a service with no usable token — never authorized, revoked, or expired beyond refresh — the request never reaches the provider. The package raises one deterministic error that signals a single thing: the service needs authorization. The client responds by running connections.authenticate.
connections.request throws NotAuthenticatedError:
e instanceof NotAuthenticatedErroristrue. It also extendsConnectionError.e.servicenames the service that needs authorization, such as'slack'.
Catch it and route the user through connections.authenticate before retrying:
import {
server,
connections,
NotAuthenticatedError,
} from '@aha-app/builder-core';
server.data('postSlackMessage', async ({ text }) => {
try {
const response = await connections.request('slack', {
method: 'POST',
path: '/api/chat.postMessage',
body: { channel: '#general', text },
});
return await response.json();
} catch (e) {
if (e instanceof NotAuthenticatedError) {
// No token for e.service. The client must run connections.authenticate(e.service).
return { error: 'not_authenticated', service: e.service };
}
throw e;
}
});The package does not convert a 401, or any other status, that the provider itself returns. That comes back as a normal Response. Only the package's own not-authenticated case throws NotAuthenticatedError, which keeps "needs authorization" distinct from "the provider rejected the call."
Removed integrations
NotAuthenticatedError covers a service the user can authorize — they have not connected yet, or the provider revoked the token, or it expired. An integration that no longer exists is a different case: an administrator removed it from the application, or nobody ever set it up. Re-running connections.authenticate cannot fix that, because there is nothing to authorize against, so the package raises a distinct error.
Note: This is not the case where a user has simply not authorized. That is always NotAuthenticatedError, even for a per-user service where most users start out unauthorized. NotConnectedError means the integration is absent for everyone, whoever calls. If any user could fix it by running connections.authenticate, the error is NotAuthenticatedError.
As with the unauthenticated case, the request never reaches the provider. connections.request throws NotConnectedError:
e instanceof NotConnectedErroristrue. It also extendsConnectionError.e.servicenames the service whose integration is missing, such as'slack'.
Keep the two cases apart, because the remedies differ. The user resolves NotAuthenticatedError by running connections.authenticate. Only an administrator resolves NotConnectedError, by re-adding the integration — so route the user there rather than back through the consent screen.
import {
server,
connections,
NotAuthenticatedError,
NotConnectedError,
} from '@aha-app/builder-core';
server.data('postSlackMessage', async ({ text }) => {
try {
const response = await connections.request('slack', {
method: 'POST',
path: '/api/chat.postMessage',
body: { channel: '#general', text },
});
return await response.json();
} catch (e) {
if (e instanceof NotConnectedError) {
// The e.service integration has been removed. Authorizing will not help — an admin must re-add it.
return { error: 'not_connected', service: e.service };
}
if (e instanceof NotAuthenticatedError) {
// No token for e.service. The client must run connections.authenticate(e.service).
return { error: 'not_authenticated', service: e.service };
}
throw e;
}
});As with not_authenticated, the package does not convert a 404 that the provider itself returns. That comes back as a normal Response. Only the package's own removed-integration case throws NotConnectedError, which keeps "the integration is gone" distinct from "the provider had no such resource."
Build it with Elle
Describe the capability you want in plain language, and Elle, the Aha! Builder AI assistant, builds it into your application using the framework above.
New to this? Open Elle, ask what it can do, and try a capability in your application today.