Skip to content

Hooks API

Identity and access providers + relations Dialog assignments assignee resolvers Host integration webhooks + startup
Identity and access providers + relations Dialog assignments assignee resolvers Host integration webhooks + startup
A hooks file registers identity and access data, dialog assignment sources, and host integrations.

Providers · Assignee resolvers · Relations · Webhooks · Startup

This page lists everything a hooks file can register, with signatures, limits and the errors the host reports. For a walkthrough, read Connect your identity provider.

How to read the TypeScript signatures

A signature like principals(account: Account): MaybePromise<readonly PrincipalRef[]> reads as: a method principals that takes an Account and returns a list of PrincipalRef, either directly or as a Promise. readonly means the callee must not change the value; ? after a field name means the field is optional.

principals(account: Account): MaybePromise<readonly PrincipalRef[]>;
  1. a value or a Promise
  2. do not change it
  3. a list of principals

Import it from @identity-flow/sdk:

export function defineHooks(
name: string,
register: (context: HookRegistrationContext) => undefined,
): ExportedHookDefinition {
register: (context: HookRegistrationContext) => undefined,
  1. must return nothing
  • The hooks file must export default the definition; other exports are not read. Every file that matches the discovery patterns must have one, so keep helper modules out of hooks/ directories.
  • name is 1–64 lowercase letters or digits, with single hyphens between them. It is the <hook> segment of webhook URLs and must be unique across all hooks files of a host. It is not a provider ID.
  • register runs once, synchronously, when the host starts, and never during workflow replay. Returning a Promise stops the start with Hooks registration in <file> cannot be asynchronous; returning any other value stops it with Hooks registration in <file> must return undefined.
  • An invalid default export stops the start with Invalid hooks definition in <file>; a repeated name with Duplicate hook "<name>" in <file>; already registered by <file>.

The host finds hooks files with the patterns **/*.hooks.?(m)[jt]s and **/hooks/*.?(m)[jt]s, in the workflow directory tree unless the hooks block of the host configuration sets cwd, path, include or exclude. Hooks are loaded once per start.

register receives one object with six members:

export interface HookRegistrationContext {
readonly identity: IdentityRegistrationContext;
readonly assignees: AssigneeRegistrationContext;
readonly authorization: AuthorizationRegistrationContext;
/** Canonical command/envelope factories; identical to `changeCommandFactories`. */
readonly commands: ChangeCommandFactories;
readonly http: HttpHookRegistrationContext;
readonly lifecycle: LifecycleRegistrationContext;
}
MemberRegisters
identity.provider(id, provider)The provider that returns an account’s principals.
assignees.resolver(providerId, resolver)The resolver for assignee sources of one provider.
authorization.principalRelation(name, adapter)A relation that access entries can target.
http.webhook(name, definition)A signed webhook that turns requests into change envelopes.
lifecycle.startup(name, start)A callback that starts an adapter when the host starts.
commandsThe factories for change commands and envelopes.
export interface Account {
readonly id: AccountId;
readonly origin: string;
readonly subject: Subject;
}
/**
* An opaque provider-owned identity, membership, or relationship key.
*
* IdentityFlow scopes equality by `providerId` and never interprets `subject`.
*/
export interface PrincipalRef {
readonly providerId: ProviderId;
readonly subject: Subject;
}
export interface IdentityProvider {
principals(account: Account): MaybePromise<readonly PrincipalRef[]>;
}
  • A provider ID is 1–128 UTF-16 code units of valid Unicode without control characters. __legacy_unscoped__ is reserved. Each provider ID may be registered once per host; a second registration stops the start with Duplicate identity provider "<id>" in <file>; already registered by <file>.
  • An origin in identity.originProviders that routes to a provider ID no hooks file registers stops the start with Origin "<origin>" routes to missing provider "<id>".
  • principals(account) returns the complete current set. A subject is 1–512 UTF-16 code units. IdentityFlow compares subjects without regard to letter case everywhere it decides access (group:Admins equals group:admins) and provider IDs exactly. Every returned principal must carry the provider’s own ID; otherwise the lookup counts as failed.
  • IdentityFlow calls it at sign-in, for reads when the stored answer is older than principalCacheTtl (default 15 minutes), and fresh for every command. A failed fresh lookup refuses the command; see the figure in When principals are looked up.
  • principals must answer within 5 seconds; a slower answer counts as a failed lookup. The limit is not configurable.
  • The default hard limit is 7,000 principals; a lookup above it is refused with AUTHORIZATION_PRINCIPAL_LIMIT_EXCEEDED. IdentityFlow never truncates the set. It logs a warning above the default review threshold of 5,000 principals.
export interface AssigneeSourceRef<Context extends JSONValue = JSONValue> {
readonly providerId: ProviderId;
readonly sourceKey: string;
readonly context: Context;
readonly sourceVersion?: string;
}
export interface AssigneeResolution {
readonly assignees: readonly AssigneeRef[];
readonly sourceVersion?: string;
}
export interface AssigneeResolverInput<Context extends JSONValue = JSONValue> {
readonly source: AssigneeSourceRef<Context>;
readonly signal: AbortSignal;
}
/** Re-resolves a complete assignee set from one snapshotted source reference. */
export interface AssigneeResolver<Context extends JSONValue = JSONValue> {
readonly context: JSONStandardSchema<Context>;
resolve(input: AssigneeResolverInput<Context>): MaybePromise<AssigneeResolution>;
}
  • One resolver per provider ID. A second one stops the start with Duplicate assignee resolver "<id>" in <file>; already registered by <file>.
  • context is a Standard Schema, for example a Valibot schema. IdentityFlow validates the stored context with it before calling resolve.
  • sourceKey and sourceVersion are 1–512 characters. context is JSON of at most 8,192 bytes and nesting depth 64.
  • resolve returns the complete new set: non-empty, at most 1,024 principals, all with the resolver’s provider ID. IdentityFlow removes duplicates and replaces the dialog’s assignees with it.
  • resolve must answer within 3 seconds; the limit is not configurable.
  • It runs only in the background, after a change event or an operator’s replay from Failed work, never during workflow replay. See Assignee sources.
export interface WorkflowInstanceRecipientAnchor {
readonly kind: 'workflowInstanceRecipient';
readonly instanceId: WorkflowInstanceId;
readonly recipient: Subject;
}
export interface PrincipalRelationProjectionContext {
readonly signal: AbortSignal;
}
/** Projects opaque provider-scoped Principals for one Engine-owned Resource anchor. */
export interface PrincipalRelationAdapter {
project(
anchor: WorkflowInstanceRecipientAnchor,
context: PrincipalRelationProjectionContext,
): MaybePromiseLike<readonly PrincipalRef[]>;
}
  • name is 1–128 characters, without leading or trailing whitespace or control characters, and not *. Each name may be registered once per host.
  • project must answer within 3 seconds.
  • project is called for each recipient of an instance and returns up to 1,024 principals. It receives no database handle and no actor.
  • An access entry principalRelation(name) matches an account that holds one of the projected principals. A relation that fails or is not registered denies access. See Access control.
export interface WebhookCallbackContext {
readonly signal: AbortSignal;
}
export interface HmacSha256WebhookAuth {
readonly type: 'hmac-sha256';
/** Resolves the secret at runtime; literal secrets are not part of hook definitions. */
readonly secret: (context: WebhookCallbackContext) => MaybePromise<string>;
readonly signatureHeader: string;
readonly timestampHeader: string;
readonly maxClockSkew: Duration;
}
export interface HmacWebhookLimits {
readonly bodyBytes?: number;
readonly eventsPerRequest?: number;
}
/** A POST-only webhook mapped to one or more canonical change envelopes. */
export interface HmacWebhookDefinition<Input extends JSONValue = JSONValue> {
readonly body: JSONStandardSchema<Input>;
readonly auth: HmacSha256WebhookAuth;
readonly limits?: HmacWebhookLimits;
map(
input: Input,
context: WebhookCallbackContext,
): MaybePromise<ChangeEnvelope | readonly ChangeEnvelope[]>;
}
  • name follows the same rule as the hook name: 1–64 lowercase letters or digits with single hyphens. It is the <webhook> URL segment and must be unique within its hooks file.
  • signatureHeader and timestampHeader are HTTP header names and must differ.
  • secret is called for each request; return the shared secret as a non-empty string. Literal secrets do not belong in a hooks file.
  • maxClockSkew is a duration such as '5 minutes'. Keep it short: with the eventId, it is what stops a captured request from being replayed. Senders must sign every attempt, including retries, with the current time.
  • limits.bodyBytes and limits.eventsPerRequest are positive integers. They can only lower the host limits of 1,000,000 bytes and 1,000 envelopes.
  • map returns one envelope or an array. It may only emit commands for a provider or resolver registered by the same hooks file.
ItemValue
EndpointPOST /-hooks/<hook>/<webhook>
TimestampUnix seconds as a decimal integer, in the timestamp header; must be within maxClockSkew of the host clock
Signaturesha256=<64 lowercase hex> in the signature header
Signed bytesidentity-flow-webhook-v1, timestamp, hook name, webhook name and raw body, joined with \n
BodyJSON, validated against body
Rate limitPer client address: burst of 8, then 1 request per second
Callbackssecret, schema validation and map share 5 seconds per request; at most 8 requests per webhook run them at once

Checks run in this order: rate limit, hook and webhook names, headers, body size, signature and timestamp, JSON and schema, map, envelope count, storage.

StatusBodyCause
202{ "results": [ { "source", "eventId", "status" } ] }Stored; status is accepted or duplicate per envelope.
400{ "error": "invalid_request_body" }Not JSON, fails the schema, no envelope, invalid envelope, or a command for a provider the hooks file does not own.
401{ "error": "authentication_required" }A header is missing. The response carries WWW-Authenticate: HMAC-SHA256.
403{ "error": "authentication_failed" }Signature mismatch or stale timestamp.
404{ "error": "webhook_not_found" }Unknown hook or webhook.
409{ "error": "change_event_conflict" }A source and eventId pair was stored before with different content. Nothing in the request is stored.
413{ "error": "request_too_large" }Body or envelope count over the limit.
429{ "error": "rate_limit_exceeded" }Rate limit. Retry-After gives the seconds to wait.
503{ "error": "change_ingress_unavailable" }secret, the schema or map threw or took longer than 5 seconds, or the host is busy or stopping.

The signed bytes are drawn, and the signing steps shown as a runnable script, in Endpoint and signature. How a stored change event is applied afterwards is drawn in After the 202.

export type LifecycleDisposer = () => MaybePromise<void>;
export interface LifecycleStartupContext {
readonly signal: AbortSignal;
submit(envelope: ChangeEnvelope): Promise<void>;
}
export type LifecycleStartup = (
context: LifecycleStartupContext,
) => MaybePromise<void | LifecycleDisposer>;
export interface LifecycleRegistrationContext {
startup(name: string, start: LifecycleStartup): void;
}
  • name follows the hook name rule and must be unique within its hooks file.
  • start runs when the host starts, after change storage is ready. It must settle within 10 seconds and return nothing or a disposer function. Otherwise the start stops with one of: Hook lifecycle startup "<hook>/<name>" failed (it threw), Hook lifecycle startup "<hook>/<name>" timed out after 10000ms, or Hook lifecycle startup "<hook>/<name>" must return undefined or a disposer function.
  • submit(envelope) stores an envelope the same way the webhook does.
  • Disposers run when the host stops, in reverse order of registration. signal is aborted when the host stops.

hooks.commands is the same object as the exported changeCommandFactories.

export interface IdentityInvalidateTarget {
readonly providerId: ProviderId;
readonly account: Pick<Account, 'origin' | 'subject'>;
}
export interface IdentityInvalidateCommand extends IdentityInvalidateTarget {
readonly type: 'identity.invalidate';
readonly version: 1;
}
export interface AssigneesSourceChangedTarget {
readonly providerId: ProviderId;
readonly sourceKey: string;
readonly sourceVersion?: string;
}
export interface AssigneesSourceChangedCommand extends AssigneesSourceChangedTarget {
readonly type: 'assignees.sourceChanged';
readonly version: 1;
}
export type ChangeCommand = IdentityInvalidateCommand | AssigneesSourceChangedCommand;
/** One transport-neutral, idempotently admitted change command. */
export interface ChangeEnvelope<Command extends ChangeCommand = ChangeCommand> {
readonly source: string;
readonly eventId: string;
readonly occurredAt?: string;
readonly command: Command;
}
/** The only factories customer hooks can use to construct inbound commands. */
export interface ChangeCommandFactories {
readonly identity: { invalidate(target: IdentityInvalidateTarget): IdentityInvalidateCommand };
readonly assignees: {
sourceChanged(target: AssigneesSourceChangedTarget): AssigneesSourceChangedCommand;
};
envelope<Command extends ChangeCommand>(input: ChangeEnvelope<Command>): ChangeEnvelope<Command>;
}
  • identity.invalidate discards the stored principals of one login, so the next lookup asks the provider.
  • assignees.sourceChanged refreshes every pending dialog whose assignee source has this provider ID and source key.
  • envelope validates the envelope and returns it; it does not submit anything. source and eventId are 1–512 characters and together identify the change; occurredAt is an optional ISO timestamp. origin, subject and sourceKey are 1–512 characters.
  • IdentityFlow applies a stored envelope in the background. A dialog refresh is tried up to 8 times, with waits from 1 second doubling to at most 5 minutes, before it is listed under Failed work.

Import it from @identity-flow/testing. It runs a hooks definition in memory, under plain Vitest, without a database or the Workflow Test distribution.

export interface HookTestHost {
load(definition: ExportedHookDefinition): void;
readonly commands: ChangeCommandFactories;
readonly identity: {
readonly calls: readonly IdentityProviderCall[];
resolve(providerId: ProviderId, account: Account): Promise<readonly PrincipalRef[]>;
};
readonly assignees: {
readonly calls: readonly AssigneeResolverCall[];
resolve(providerId: ProviderId, input: AssigneeResolverInput): Promise<AssigneeResolution>;
};
readonly authorization: {
readonly calls: readonly PrincipalRelationCall[];
project(
name: string,
anchor: WorkflowInstanceRecipientAnchor,
signal?: AbortSignal,
): Promise<readonly PrincipalRef[]>;
};
readonly http: {
readonly calls: readonly HttpWebhookCall[];
readonly webhooks: readonly RegisteredWebhook[];
map(
hookName: string,
name: string,
input: JSONValue,
): Promise<ChangeEnvelope | readonly ChangeEnvelope[]>;
};
readonly lifecycle: {
readonly submissions: readonly ChangeEnvelope[];
start(signal?: AbortSignal): Promise<void>;
stop(): Promise<void>;
};
}
  • load(definition) runs register. Calling it with more than one definition registers them side by side; duplicates throw.
  • identity.resolve, assignees.resolve and authorization.project call the registered callback and record the call in calls. The resolver’s context, the resolution, relation results and envelopes are validated as the host validates them; identity.resolve returns the provider’s principals unchecked.
  • http.map(hookName, name, input) validates the body against the schema and runs map. It does not test HTTP, headers or the signature. http.webhooks lists the registered webhooks.
  • lifecycle.start(signal?) runs every startup callback; submitted envelopes collect in lifecycle.submissions. lifecycle.stop() runs the disposers in reverse order.

The package also exports createFakeIdentityProvider({ principals }) and createFakeAssigneeResolver({ context, resolution }), which return a provider or resolver that records its calls in calls. principals is a fixed list or a function; resolution is a fixed result or a function.

A complete test is in Test the hooks file; a smaller one is in the identity-hook recipe.