Hogsend
API Reference

Buckets API

The defineBucket() object surface (typed refs, on() reactions, member access) plus the admin HTTP endpoints -- listing with live counts, detail with criteria and fed journeys, member listing, and enable/disable.

This page has two halves: the DefinedBucket object returned by defineBucket() (typed transition refs, the .on() reaction surface, and in-process member access), and the admin HTTP endpoints for inspecting and toggling buckets at runtime. For the authoring concepts -- criteria, the fluent builder, re-entry, the reconcile cron -- see the Buckets guide. All HTTP endpoints below require the Authorization: Bearer <ADMIN_API_KEY> header.

Buckets are real-time, code-defined membership groups (a peer of journeys). The admin endpoints let you see which buckets are registered, inspect their criteria and which journeys they feed, list current and former members, and enable/disable them at runtime.

The DefinedBucket object

defineBucket({ meta }) returns a DefinedBucket<Id> -- the object you export from your bucket file. It is generic over the bucket's id literal, so the transition refs and reaction triggers are typed down to the exact string.

import { days, defineBucket, sendEmail } from "@hogsend/engine";
import { Events, Templates } from "../journeys/constants/index.js";

export const wentDormant = defineBucket({
  meta: {
    id: "went-dormant",
    name: "Went dormant",
    enabled: true,
    timeBased: true,
    criteria: (b) =>
      b.all(
        b.event(Events.APP_ACTIVE).exists(),
        b.event(Events.APP_ACTIVE).within(days(7)).notExists(),
      ),
  },
});
interface DefinedBucket<Id extends string = string> {
  meta: BucketMeta;

  // Typed transition refs (literal-typed off `Id`)
  readonly entered: `bucket:entered:${Id}`;
  readonly left: `bucket:left:${Id}`;

  // Generated reaction journeys (read by the worker + container)
  reactions: DefinedJourney[];

  // Member access -- never an unbounded array
  count(): Promise<{ data: number | null; error: Error | null }>;
  has(userId: string): Promise<{ data: boolean; error: Error | null }>;
  members(opts?: { limit?: number; cursor?: string }): Promise<MembersResult>;
  membersIterator(opts?: { pageSize?: number }): AsyncIterableIterator<BucketMemberRow>;

  // Colocated reactions (returns the bucket, chainable)
  on(kind: "enter", handler): DefinedBucket<Id>;
  on(kind: "enter", opts: EnterOptions, handler): DefinedBucket<Id>;
  on(kind: "leave", handler): DefinedBucket<Id>;
  on(kind: "leave", opts: LeaveOptions, handler): DefinedBucket<Id>;
  on(kind: "dwell", opts: DwellOptions, handler): DefinedBucket<Id>;
}

Typed transition refs

bucket.entered and bucket.left are the bucket's two transition event names, computed from meta.id synchronously at defineBucket() time as pure string concatenation. Because they are typed as the literal `bucket:entered:${Id}` / `bucket:left:${Id}`, a typo can't slip past the compiler -- they are the type-safe replacement for hand-written strings.

wentDormant.entered; // "bucket:entered:went-dormant"
wentDormant.left;    // "bucket:left:went-dormant"

// Use them directly as a journey trigger / exit:
defineJourney({ meta: { trigger: { event: wentDormant.entered }, /* … */ } });
defineJourney({ meta: { exitOn: [{ event: wentDormant.left }], /* … */ } });

The typed refs replace the bucketEntered() / bucketLeft() string helpers and the hand-maintained BucketId union, which are deprecated for one release (still exported, slated for removal). Migrate a journey by importing the bucket from its leaf module (e.g. ../buckets/went-dormant.js, not the barrel) and reading bucket.left. The generic Events.BUCKET_ENTERED / Events.BUCKET_LEFT constants remain for the any-bucket case.

on(kind, opts?, handler)

bucket.on(...) colocates a reaction with the bucket. Each call desugars to a real durable journey (full JourneyContext, the entire enrollment guard stack, active-state dedup, event routing) tagged with sourceBucketId + reactionKind, pushed onto bucket.reactions. on() returns the bucket, so calls chain. Registration is declarative -- there is no .subscribe() step.

wentDormant
  .on("enter", async (user, ctx) => {
    if (!ctx.isFirstEntry) return;            // entryCount / isFirstEntry on ctx
    await ctx.sleep({ duration: days(1) });
    await sendEmail({ to: user.email, template: Templates.WINBACK_WE_MISS_YOU });
  })
  .on("leave", { reason: "criteria" }, async (user, ctx) => {
    // ctx.reason is "criteria" | "maxDwell" | "manual"
  })
  .on("dwell", { after: days(7) }, async (user, ctx) => {
    // fires from the reconcile cron for members dormant >= 7 days; ctx.dwellCount
  });

The first argument is the transition kind; the second is optional options for enter/leave and mandatory for dwell. The overloads:

KindSignatureoptsTrigger eventctx extras
enteron("enter", handler) or on("enter", opts, handler)EnterOptions (optional)bucket.entered{ entryCount: number; isFirstEntry: boolean }
leaveon("leave", handler) or on("leave", opts, handler)LeaveOptions (optional)bucket.left{ reason: "criteria" | "maxDwell" | "manual" }
dwellon("dwell", opts, handler)DwellOptions (required)bucket:dwell:<id>:<label>{ dwellCount: number }

The handler is (user: JourneyUser, ctx) => Promise<void> -- the same shape as a journey run, with the kind-specific extras layered onto the canonical JourneyContext (built by spread, so the engine's ctx is never mutated).

Options:

interface EnterOptions {
  // Only run on the user's FIRST-ever entry to this bucket; re-entries are skipped.
  firstEntryOnly?: boolean;
}

interface LeaveOptions {
  // Only run when the leave matches this reason (or one of these reasons).
  reason?: BucketLeaveReason | BucketLeaveReason[];
}

// Exactly one of after / every.
type DwellOptions =
  | { after: DurationObject; every?: never }   // one-shot at the threshold
  | { every: DurationObject; after?: never };  // recurring, once per elapsed interval

type BucketLeaveReason = "criteria" | "maxDwell" | "manual";
  • firstEntryOnly and reason are filters applied inside run after enrollment -- a filtered-out reaction still writes a short active->completed journeyStates row, it just does no work. Re-entry is never a separate event.
  • dwell requires exactly one of after / every; passing both, neither, or a non-function handler throws a TypeError at definition time.
  • Reaction metas are entryLimit: "unlimited", suppress: { seconds: 0 } by design -- a reaction has no re-entry cool-down; gate on ctx.entryCount / firstEntryOnly instead.

Generated reactions are ENABLED_BUCKETS-gated (they inherit the bucket's enabled state, not ENABLED_JOURNEYS) and are grouped under their bucket in Studio and in feedsJourneys as owned: true. dwell reactions fire from the reconcile cron, not a real-time event -- see Bucket Operations.

Member access

Four read methods let you query live membership in-process (e.g. from a journey run or a custom workflow). They follow a Supabase-shaped { data, error } contract and never throw on a query failure (the iterator throws on a page error) -- failures land in error. Member access is never an unbounded array: members() is keyset-paginated and the iterator pages internally. Every query inner-joins live contacts and filters soft-deleted rows on both tables (GDPR parity with the admin/reconcile queries).

const { data: total, error } = await wentDormant.count();      // active head-count
const { data: isMember } = await wentDormant.has(userId);      // O(1) membership probe
const page = await wentDormant.members({ limit: 50 });         // one keyset page
for await (const m of wentDormant.membersIterator()) { /* paged internally */ }
MethodReturnsNotes
count(){ data: number | null; error }Active, non-deleted members joined to a live contact. The one authoritative count.
has(userId){ data: boolean; error }O(1) probe on the partial active unique index.
members(opts?)MembersResultOne keyset page. limit default 50, hard cap 100.
membersIterator(opts?)AsyncIterableIterator<BucketMemberRow>Walks the whole active population, page-by-page; pageSize default 50, cap 100. Throws on a page error.
interface BucketMemberRow {
  id: string;
  userId: string;
  userEmail: string | null;
  enteredAt: string;   // ISO
  entryCount: number;
}

interface MembersResult {
  data: BucketMemberRow[];
  error: Error | null;
  count: number | null;  // per-call snapshot total -- NOT a stable paginated total
  cursor: string | null; // keyset continuation (last row id); null when exhausted
}
  • Cursor. members() paginates with a keyset cursor on the row id (a stable UUID), not enteredAt. Pass the returned cursor back as opts.cursor for the next page; null means the page is exhausted. Order is opaque (UUID ascending), not chronological -- a time-ordered walk is not provided.
  • count on a page is a snapshot. MembersResult.count is a per-call head-count; under churn it can drift page-to-page. The cursor itself is churn-safe. Use the standalone count() for one authoritative number.

Buckets

GET /v1/admin/buckets

List all registered buckets with their effective enabled status and live membership counts.

Query Parameters

ParamTypeDefaultDescription
limitnumber50Results per page (1-100)
offsetnumber0Pagination offset
enabled"true" | "false"--Filter by effective enabled status

Response 200

{
  "buckets": [
    {
      "id": "power-users",
      "name": "Power users",
      "description": "Performed a key action 10+ times in the last 30 days.",
      "enabled": true,
      "kind": "dynamic",
      "timeBased": true,
      "entryLimit": "once_per_period",
      "counts": {
        "active": 184,
        "left": 57
      }
    }
  ],
  "total": 3,
  "limit": 50,
  "offset": 0
}

The enabled field reflects the effective state -- if an admin has toggled a bucket via PATCH, that override takes precedence over the code-level default. counts.active is the current size; counts.left is the number of historical departures. The enabled query filter is applied against this effective state.

curl -H "Authorization: Bearer your-admin-api-key" \
  "http://localhost:3002/v1/admin/buckets?enabled=true"

GET /v1/admin/buckets/{id}

Get full bucket detail including the membership criteria, reconciliation knobs, which journeys the bucket feeds, state counts, and the 10 most recent active members.

Path Parameters

ParamTypeDescription
idstringBucket ID (e.g., power-users)

Response 200

{
  "bucket": {
    "id": "power-users",
    "name": "Power users",
    "description": "Performed a key action 10+ times in the last 30 days.",
    "enabled": true,
    "kind": "dynamic",
    "timeBased": true,
    "entryLimit": "once_per_period",
    "criteria": {
      "type": "event",
      "eventName": "key.action",
      "check": "count",
      "operator": "gte",
      "value": 10,
      "within": { "hours": 720 }
    },
    "entryPeriod": { "hours": 168 },
    "minDwell": null,
    "reconcileEvery": null,
    "fastExpiry": false,
    "syncToPostHog": false,
    "counts": { "active": 184, "left": 57 },
    "feedsJourneys": [
      {
        "id": "bucket-power-users-on-enter",
        "name": "Power users -- on enter",
        "trigger": "bucket:entered:power-users",
        "sourceBucketId": "power-users",
        "owned": true
      },
      {
        "id": "power-user-celebration",
        "name": "Power user -- Celebration",
        "trigger": "bucket:entered:power-users",
        "sourceBucketId": null,
        "owned": false
      }
    ],
    "recentMembers": [
      {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "userId": "user_abc123",
        "userEmail": "user@example.com",
        "bucketId": "power-users",
        "status": "active",
        "enteredAt": "2025-01-15T10:30:00.000Z",
        "leftAt": null,
        "expiresAt": "2025-02-14T10:30:00.000Z",
        "lastEvaluatedAt": "2025-01-15T10:30:00.000Z",
        "entryCount": 1,
        "source": "event",
        "context": {},
        "createdAt": "2025-01-15T10:30:00.000Z",
        "updatedAt": "2025-01-15T10:30:00.000Z"
      }
    ]
  }
}

criteria is the raw ConditionEval tree from the bucket definition (the same condition engine journeys use). entryPeriod, minDwell, and reconcileEvery are returned as their DurationObject shape or null when unset.

feedsJourneys lists every journey this bucket drives, from two sources, owned-first:

  1. Owned reactions -- journeys generated by the bucket's own .on("enter" | "leave" | "dwell", …) calls. Each desugars to a durable journey tagged with sourceBucketId === id; they are discovered by scanning the registry for that tag and surfaced with owned: true (and sourceBucketId set to this bucket's id).
  2. External bindings -- hand-written journeys bound to the bucket's emitted transition events: the per-bucket alias bucket:entered:<id> / bucket:left:<id> (the recommended, narrowly-routed binding) or the generic bucket:entered / bucket:left. These are surfaced with owned: false, and sourceBucketId is whatever the journey itself carries (null for an ordinary journey).

The trigger field shows the exact event each journey binds to. On a collision -- a reaction that is also reachable via the alias cross-reference -- the owned entry wins. This is how you confirm a membership change actually kicks off the journeys you expect, and which of them the bucket itself authored. recentMembers contains up to the 10 most recently entered active members.

Response 404 -- Bucket not found.

curl -H "Authorization: Bearer your-admin-api-key" \
  http://localhost:3002/v1/admin/buckets/power-users

PATCH /v1/admin/buckets/{id}

Enable or disable a bucket at runtime. This persists to the database (bucketConfigs) and overrides the code-level enabled default -- no redeploy needed. A disabled bucket stops evaluating membership on incoming events; existing memberships are not torn down.

Path Parameters

ParamTypeDescription
idstringBucket ID

Request Body

{ "enabled": false }
FieldTypeRequiredDescription
enabledbooleanYesWhether the bucket should evaluate membership on new events

Response 200

{
  "bucket": {
    "id": "power-users",
    "name": "Power users",
    "enabled": false,
    "updatedAt": "2025-01-15T10:30:00.000Z"
  }
}

Response 404 -- Bucket not found.

curl -X PATCH http://localhost:3002/v1/admin/buckets/power-users \
  -H "Authorization: Bearer your-admin-api-key" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": false }'

Members

GET /v1/admin/buckets/{id}/members

List a bucket's members, filtered by status. Results are ordered by enteredAt descending.

Path Parameters

ParamTypeDescription
idstringBucket ID

Query Parameters

ParamTypeDefaultDescription
limitnumber50Results per page (1-100)
offsetnumber0Pagination offset
status"active" | "left"activeMembership status to list
userIdstring--Filter to a single user's membership rows

Response 200

{
  "members": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "userId": "user_abc123",
      "userEmail": "user@example.com",
      "bucketId": "power-users",
      "status": "active",
      "enteredAt": "2025-01-15T10:30:00.000Z",
      "leftAt": null,
      "expiresAt": "2025-02-14T10:30:00.000Z",
      "lastEvaluatedAt": "2025-01-15T10:30:00.000Z",
      "entryCount": 1,
      "source": "event",
      "context": {},
      "createdAt": "2025-01-15T10:30:00.000Z",
      "updatedAt": "2025-01-15T10:30:00.000Z"
    }
  ],
  "total": 184,
  "limit": 50,
  "offset": 0
}
FieldTypeDescription
status"active" | "left"Whether the user is currently in the bucket or has left it.
enteredAtstringWhen this membership row was created (the join).
leftAtstring | nullWhen the user left. null for active members.
expiresAtstring | nullArmed deadline for time-based / fastExpiry buckets. null for non-time-based buckets.
lastEvaluatedAtstring | nullLast time membership was recomputed for this row.
entryCountnumberRe-entry ordinal -- how many times this user has joined this bucket.
sourcestring | nullWhat produced the row: "event", "reconcile", "backfill", or "manual".
contextobjectSnapshot of the properties carried at the transition.

Response 404 -- Bucket not found.

# Current members
curl -H "Authorization: Bearer your-admin-api-key" \
  "http://localhost:3002/v1/admin/buckets/power-users/members?status=active"

# Past members of this bucket
curl -H "Authorization: Bearer your-admin-api-key" \
  "http://localhost:3002/v1/admin/buckets/power-users/members?status=left"

# A single user's membership rows
curl -H "Authorization: Bearer your-admin-api-key" \
  "http://localhost:3002/v1/admin/buckets/power-users/members?userId=user_abc123"

On this page