Typed Integrations
This guide explains how to use the generic type parameter on defineIntegration so your integration handlers get accurate TypeScript types for locals.adapterData, per-handler requestData, and optional per-handler locals fields.
Overview
When you call defineIntegration, you can pass a type configuration as the generic argument:
import { defineIntegration } from '@ci-hub/integration-sdk'
defineIntegration<{
adapterData: { brandId: string }
login: {
requestData: {
query: { brandId?: string; code?: string }
body: { brandId?: string }
}
}
}>({
name: 'My Integration',
version: '1.0.0',
// ... handlers
})Inside each handler, TypeScript uses that configuration to type:
locals— includinglocals.adapterDatamerged with your custom fieldsrequestData—query,body, andparamsmerged with the SDK's base types for that handler (e.g.LoginRequestforlogin)
This is compile-time only. Runtime behavior (headers, JSON parsing, Zod validation) is unchanged. You are responsible for actually setting adapterData and request fields your types describe.
The same config type can be passed manually to sendAccessToken, getStateAdapterDataAsync, and setStateAdapterDataAsync (see Auth helpers). DAH uses a separate DahTypeConfig for adapterData and data.
Generic types are optional but helpful — they replace manual as casts with accurate compile-time checking. You can add them at any point during development.
Type configuration shape
The generic argument must match IntegrationTypeConfig:
| Key | Purpose |
|---|---|
adapterData | Fields stored on the connection and passed in locals.adapterData for every handler |
login, search, getFolder, … | Per-handler extensions (see Supported handler keys) |
Each handler key is optional and may include:
{
locals?: Record<string, unknown> // extra fields on locals for this handler only
oauthState?: Record<string, unknown> // login OAuth temp state (see Auth helpers)
requestData?: {
query?: Record<string, unknown> // merged into the handler's default query type
body?: Record<string, unknown> // merged into the handler's default body type
params?: Record<string, unknown> // merged into the handler's default params type
}
}Only use handler names and adapterData at the top level. Do not put name, version, or capabilities in the generic config — those belong in the implementation object.
How merging works
adapterData:locals.adapterDatabecomes the SDK base (resourceEndpointplus loose keys) intersected with your declared fields.requestData: Each part you declare is intersected with the SDK default for that handler. For example,loginalways includes baseLoginRequestfields (endpointUrl, optionalstate, etc.); yourquery/bodyextensions add fields likebrandIdorcode.locals(per handler): If you setlogin.locals, only theloginhandler'slocalsparameter includes those extra fields (plus globaladapterData). Other handlers use the global shape unless you configure them too.
Invalid handler names in the generic (typos like loginn) produce a TypeScript error.
Supported handler keys
You may configure any of these keys under the generic (same names as on defineIntegration):
info, login, logout, checkToken, refreshToken, search, getFolder, createFolder, createAsset, updateAsset, lockAsset, renameAsset, moveAsset, deleteAsset, renameFolder, moveFolder, deleteFolder, getAssetVersions, download, getBrandConfig, getBrandAssets, searchTasks, getTask, getTaskAssets, addTaskComment, addTaskAsset, updateCustomTaskState, deleteTaskAsset, addAssetToTask, updateTaskState
Handlers that only receive locals (no requestData): info, checkToken, refreshToken.
Custom methods
Any key on defineIntegration that is not in the standard handler list above is treated as a custom method — a server-side function callable via /api/v1/system/providerInternal/:methodName. Custom method keys use CustomMethodTypeExtension in the generic config, which supports locals, requestData, and returnType:
{
locals?: Record<string, unknown>
requestData?: {
query?: Record<string, unknown>
body?: Record<string, unknown>
params?: Record<string, unknown>
}
returnType?: Record<string, unknown>
}Example with a sign method:
type MyIntegrationConfig = {
adapterData: { secretKey: string }
sign: {
requestData: {
body: { uploadParams: Record<string, unknown> }
}
returnType: { signature: string; timestamp: number }
}
}defineIntegration<MyIntegrationConfig>({
sign: async (locals, requestData) => {
locals.adapterData.secretKey // string
requestData.body.uploadParams // Record<string, unknown>
return send({ signature: '...', timestamp: Date.now() })
}
})returnType is informational — it types the argument passed to send() inside the handler. Custom methods are not validated against Zod response schemas at runtime.
See Custom Methods for the full guide including how to call custom methods from the DAH.
Examples
Global adapterData only
Use this when every handler reads the same connection-specific data (brand ID, tenant URL, etc.):
defineIntegration<{
adapterData: {
brandId: string
resourceEndpoint: string
}
}>({
name: 'My DAM',
version: '1.0.0',
info: async locals => {
const { brandId } = locals.adapterData // string
// ...
},
search: async (locals, requestData) => {
const { brandId } = locals.adapterData // string — same in all handlers
// requestData uses default SearchQuery / SearchBody types
}
})Set adapterData when the user connects, typically in login or refreshToken:
return sendAccessToken(null, accessToken, expiresIn, refreshToken, undefined, {
resourceEndpoint: serverUrl,
brandId: selectedBrandId
})Per-handler requestData (login)
OAuth and custom login flows often need extra query/body fields beyond LoginRequest:
defineIntegration<{
adapterData: { brandId: string; serverUrl?: string }
login: {
requestData: {
query: {
brandId?: string
serverUrl?: string
code?: string
error?: string
error_description?: string
canceled?: boolean
state?: string
}
body: {
brandId?: string
serverUrl?: string
canceled?: boolean
state?: string
}
}
}
}>({
login: async (locals, requestData) => {
const code = requestData.query.code
const brandId = requestData.body.brandId || requestData.query.brandId || ''
const serverUrl = requestData.body.serverUrl || requestData.query.serverUrl || ''
// endpointUrl still required on base LoginRequest where applicable
}
})Per-handler locals
Rarely needed if adapterData covers your case. Use when a single handler needs extra locals fields that other handlers should not see in the type system:
defineIntegration<{
adapterData: { brandId: string }
search: {
locals: {
debugSessionId?: string
}
}
}>({
search: async locals => {
locals.adapterData.brandId // string
locals.debugSessionId // string | undefined — only on search
}
})You must populate any custom locals fields yourself if you rely on them; the SDK only supplies the standard Locals shape from headers.
Reusing a config type
Define a shared type alias and pass it to every API that accepts the same config:
export type MyIntegrationConfig = {
adapterData: { brandId: string }
login: {
oauthState: { serverUrl?: string; verifier?: string }
requestData: {
query: { code?: string }
body: { brandId?: string }
}
}
}
defineIntegration<MyIntegrationConfig>({ /* ... */ })Auth helpers
Pass the same MyIntegrationConfig type to:
| Function | Typed usage |
|---|---|
sendAccessToken<TConfig>(..., adapterData?) | Last argument: AdapterDataFromConfig<TConfig> |
getStateAdapterDataAsync<TConfig>(state) | Returns OAuthStateFromConfig<TConfig> | null |
setStateAdapterDataAsync<TConfig>(state, data) | data: OAuthStateFromConfig<TConfig> |
OAuthStateFromConfig is read from login.oauthState (temporary OAuth flow data, not connection adapterData).
const stored = await getStateAdapterDataAsync<MyIntegrationConfig>(state)
await setStateAdapterDataAsync<MyIntegrationConfig>(state, { serverUrl, verifier: '...' })
return sendAccessToken<MyIntegrationConfig>(null, token, expiresIn, refresh, undefined, {
resourceEndpoint: serverUrl,
brandId: '...'
})Direct Access Helper (DAH)
DAH runs in a separate bundled file. Use DahTypeConfig and pass it to DahFunction / DirectAccessHelper:
export type MyDahConfig = {
adapterData: { resourceEndpoint: string }
}
const upload: DahFunction<MyDahConfig> = async (axios, _p, _s, data, onProgress, adapterData, _t, helpers) => {
adapterData.resourceEndpoint
data.file
helpers.getAdapterData().resourceEndpoint
return { id: '...', name: 'file' }
}
export default { createAsset: upload } satisfies DirectAccessHelper<MyDahConfig>Share adapterData field types via the same integration-types.ts file as your integration config.
Exported types
Import these from @ci-hub/integration-sdk when building helpers or wrapping handlers:
| Type | Description |
|---|---|
IntegrationTypeConfig | Shape of the generic argument |
IntegrationDefinition<TConfig> | Full integration object type for a given config |
Integration<TConfig> | Alias of IntegrationDefinition<TConfig> (default TConfig is empty) |
IntegrationLocals<TAdapter> | Locals with typed adapterData |
HandlerLocals<TAdapter, THandler> | Locals for one handler including optional per-handler locals |
MergeRequestData<Base, Ext> | How requestData is computed for a handler |
HandlerRequestDefaults | Map of default TypedRequestData per handler name |
IntegrationHandlerName | Union of valid handler keys |
HandlerTypeExtension | { locals?, oauthState?, requestData? } for one handler entry |
CustomMethodTypeExtension | { locals?, requestData?, returnType? } for a custom method entry |
AdapterDataFromConfig<TConfig> | adapterData shape for sendAccessToken and merged locals |
OAuthStateFromConfig<TConfig> | login.oauthState shape for state helpers |
DahTypeConfig | Generic config for DAH (adapterData, data) |
DahFunction<TConfig> | Typed DAH handler (adapterData + data params) |
DahHelpers<TConfig> | Typed helpers including getAdapterData() |
DahAdapterDataFromConfig<TConfig> | Adapter slice for DAH |
DahDataFromConfig<TConfig> | DahData merged with config data |
DirectAccessHelper<TConfig> | Object exported from dah.ts |
What is not typed yet
- Cross-file inference — you must pass the same config type alias to
defineIntegration, auth helpers, and DAH separately (recommended: oneintegration-types.tsfile)
Tips and limitations
- Declare fields you actually use — Types do not validate runtime JSON; wrong or missing
adapterDatastill fails at runtime. - Optional vs required — Use optional properties (
brandId?: string) for values that appear only in some login steps; use required (brandId: string) when every authenticated handler expects them after login. - Base request types still apply — Extensions add fields; they do not remove SDK-required fields on
LoginRequest,SearchQuery, etc.