Authentication
Declaring credentials or OAuth for a package, how tokens are refreshed, and how requests paginate, retry and handle rate limits.
A package declares how it authenticates once, on its connection. After that, your sync and verify
code calls ctx.fetch, ctx.json or ctx.paginate with a path, and the SDK adds the credentials,
refreshes tokens and retries transient failures. Your code never reads a credential or a token.
Choose an auth mode
Set connection.auth to one of these helpers, or leave it out for a public API. The helper defines
the credential fields a user fills in.
| Helper | Credential fields | Mode in Beetl |
|---|---|---|
(no auth) | None | No authentication |
auth.bearer() | token | Credentials |
auth.basic() | username, password | Credentials |
auth.apiKey({ in, name }) | apiKey, sent as the header or query parameter name | Credentials |
auth.custom({ credentials, headers, query }) | Fields you declare | Credentials |
auth.tokenExchange({ credentials, request, response }) | Fields you declare | Credentials |
auth.oauth2({ … }) | clientId, plus clientSecret when you set clientSecret: true | OAuth2 authorization code |
See Connections for how users save and reuse credentials.
For custom and tokenExchange, declare the fields as a z.strictObject of strings and place them
with { credential: "<field>" }. Wrap sensitive fields in secret() so the form masks them. Every
field must be used, and none can have a default.
import { auth, secret, z } from "@beetlio/connect";
const authentication = auth.custom({
credentials: z.strictObject({
accountId: z.string().min(1),
token: secret(z.string().min(1)),
}),
headers: {
"x-account": { credential: "accountId" },
authorization: { credential: "token" },
},
});tokenExchange trades credentials for a short-lived session token at a path on the provider's
origin, then sends it as Authorization: Bearer unless you configure session. It exchanges before
a run's first request, shortly before the token expires, and after a 401. Any authenticated origin
must use HTTPS.
OAuth2 authorization code
| Option | Meaning |
|---|---|
issuer | The authorization server's issuer identifier, usually its base URL |
authorizationUrl | The sign-in and consent page; query parameters you add are kept |
tokenUrl | Exchanges codes and refresh tokens for access tokens |
scopes | At least 1, sent space-separated |
clientSecret | true if the provider requires a secret; it is sent in the token request body |
tokenFields | Optional; copies string fields from the token response |
The SDK always uses PKCE with S256. All 3 URLs must use HTTPS. Each can be absolute, or a path on the connection's origin if that origin is known before sign-in (a fixed URL or one picked from connection settings).
If an API returns a per-account base URL in the token response, copy it with tokenFields and use it
as the origin. The OAuth URLs must then be absolute.
connection: {
origin: { type: "oauth", oauthTokenField: "instanceUrl" },
auth: auth.oauth2({
issuer: "https://login.example.com",
authorizationUrl: "https://login.example.com/oauth/authorize",
tokenUrl: "https://login.example.com/oauth/token",
scopes: ["api", "refresh_token"],
clientSecret: true,
tokenFields: { instanceUrl: "instance_url" },
}),
},What the user sees
The user registers Beetl's redirect URL with the provider's OAuth app, enters the app's client ID and secret, and chooses Connect with OAuth. Sign-in opens in a popup and has to finish within 10 minutes. Each connection holds its own authorization. See Integrations for the redirect URL and steps.
How tokens are refreshed
The SDK sends the access token as Authorization: Bearer on every request. When the provider
answers 401, it refreshes the token and repeats the request once, and concurrent requests share one
refresh. Beetl keeps the new access token, and a rotated refresh token, for later runs.
- Without a refresh token, nothing is refreshed and the 401 reaches your code as an
HttpError. Many providers issue one only for a scope such asoffline_accessor a parameter such asaccess_type=offline. - Only a 401 triggers a refresh. If the provider signals an expired token with another status, the request fails.
- If the provider rejects the refresh token (
invalid_grantoraccess_denied), the connection shows Reauthorization required and its syncs stop until someone chooses Reconnect with OAuth. If the token endpoint is only temporarily down, the run fails and the authorization stays.
Requests
ctx.fetch(path, init?) returns a standard Response. ctx.json(path, schema, init?) parses a
successful JSON body with a Zod schema and throws an HttpError (with status, a shortened
detail and any requestId) for any other status.
Paths are relative to the connection's origin. An absolute URL works only on that origin; anything else is rejected. Redirects are not followed. One response can be at most 16 MiB, so request smaller pages if needed.
Pagination
ctx.paginate fetches a page, parses it with your schema, and passes the data, response status and
headers, request and page index to next. Return the next request, or undefined to stop. An
absolute URL from a Link header works if it is on the connection's origin. A call fails if it
repeats a request or passes 10,000 pages.
Retries and rate limits
connection.retry setting | Default |
|---|---|
maxAttempts | 3 |
statuses | 408, 429, 500, 502, 503, 504 |
methods | GET, HEAD, OPTIONS |
initialDelayMs | 500 |
maxDelayMs | 30,000 |
Requests with a listed method are retried on those statuses and on network errors. The wait starts
at initialDelayMs and doubles up to maxDelayMs. A Retry-After header, in seconds or as a date,
replaces the wait, still capped at maxDelayMs. Set retry: false to turn retries off, and add
POST only for requests that are safe to repeat.
When attempts run out, ctx.json throws and ctx.fetch returns the last response. Waits count
toward the run's time limit. If the provider reports its remaining quota,
slow down before you hit a 429, as the example below does.
Example
This sync reads contacts from an OAuth API with cursor pagination. It fetches only contacts changed
since the last committed run, and each record carries its id and updated_at so a pipeline can
keep the latest version of each contact. It is meant for the Append write mode.
import { setTimeout as sleep } from "node:timers/promises";
import { auth, defineIntegration, z } from "@beetlio/connect";
const Contact = z.object({
id: z.string(),
email: z.string().nullable(),
updated_at: z.string(),
});
const ContactPage = z.object({
data: z.array(Contact),
next_cursor: z.string().nullable(),
});
export default defineIntegration({
key: "acme-crm",
displayName: "Acme CRM",
connection: {
origin: "https://api.acme.example",
auth: auth.oauth2({
issuer: "https://login.acme.example",
authorizationUrl: "https://login.acme.example/oauth/authorize",
tokenUrl: "https://login.acme.example/oauth/token",
scopes: ["contacts:read", "offline_access"],
clientSecret: true,
}),
retry: { maxAttempts: 5, maxDelayMs: 60_000 },
async verify(ctx) {
await ctx.json("/v1/me", z.object({ id: z.string() }));
},
},
syncs: (sync) => ({
contacts: sync({
records: Contact,
checkpoint: z.object({ updatedSince: z.string() }),
async *run(ctx) {
const since = ctx.checkpoint?.updatedSince ?? "1970-01-01T00:00:00Z";
let newest = since;
for await (const page of ctx.paginate({
request: { path: "/v1/contacts", query: { updated_since: since, limit: 100 } },
schema: ContactPage,
next: ({ data, request }) =>
data.next_cursor === null
? undefined
: { ...request, query: { ...request.query, cursor: data.next_cursor } },
})) {
for (const contact of page.data.data) {
if (contact.updated_at > newest) newest = contact.updated_at;
}
yield { records: page.data.data, checkpoint: { updatedSince: newest } };
if (page.response.headers["x-ratelimit-remaining"] === "0") {
await sleep(1_000, undefined, { signal: ctx.signal });
}
}
},
}),
}),
});Each page becomes one batch. Yielding the newest timestamp per page is safe because Beetl saves the
checkpoint only when the run's write commits. Header names in page.response.headers are lowercase.
Related
Last updated on