Scheduled (EventBridge) events

Scheduled work is dispatched by exactly the same mechanism as queue work. The difference is only where the routing key comes from: an EventBridge rule’s Input override supplies a detail-type at the event root.

The Input pattern

Events:
  EmailClickReEngagement:
    Type: Schedule
    Properties:
      Name: !Sub "${AWS::StackName}-email-click-reengagement"
      Description: "Re-engage contacts who clicked emails"
      Schedule: rate(6 hours)
      Enabled: true
      Input: '{"detail-type":"Hubspot.EmailClickReEngagement","source":"morpheus.hubspot","detail":{}}'

Input replaces the whole event body, so the delivered event is exactly that JSON — none of EventBridge’s usual envelope fields (version, id, account, region, time) are present.

The detail-type string must match a key in the function’s registry exactly. A mismatch produces "No handler registered for detail-type" and the invocation returns successfully having done nothing — a silent failure. This is the most common mistake when adding a schedule.

ScheduledEvent

Each function defines its own, all identical:

class ScheduledEvent(BaseModel):
    """Generic EventBridge scheduled-rule event."""
    model_config = ConfigDict(extra="ignore")
    source: str = ""

extra="ignore" tolerates the whole envelope, and the only declared field is optional. A scheduled event carries no payload, so there is nothing to share between functions and nothing to validate — the model exists purely so the dispatcher’s model_validate() call is uniform.

Two scraper events are richer, because they do carry parameters:

class SellerDiscoveryDetail(BaseModel):
    model_config = ConfigDict(extra="allow")
    marketplace: str = "amazon"
    provider: str = "smartscout"
    max_pages: int | None = None

class SellerDiscoveryEvent(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    detail_type: str = Field(alias="detail-type")
    source: str
    id: str | None = None            # optional: an Input override omits the envelope
    version: str | None = None
    account: str | None = None
    region: str | None = None
    detail: SellerDiscoveryDetail = Field(default_factory=SellerDiscoveryDetail)

Every detail field defaults, so the rule’s Input need only specify what differs.

The catalogue

enrichment-scraper

detail-type Model Purpose

SellerDiscovery.Amazon

SellerDiscoveryEvent

Paginated SmartScout discovery — rules

SellerDiscovery.Walmart

SellerDiscoveryEvent

Stub, raises NotImplementedError

SellerDiscovery.RunCategory

RunCategoryEvent

Stub, ad-hoc per-category discovery

Scraper.BigQueryContactSync

ScheduledEvent

Incremental BigQuery contact mirror

enrichment-scorer

detail-type Model Purpose

Seller.EnrichPending

ScheduledEvent

Enrich up to 10 sellers with lifeCycleStage = new and enrichedAt IS NULL

Seller.ScorePending

ScheduledEvent

Score up to 10 enriched sellers with payoutPainScore IS NULL

morpheus-hubspot

detail-type Model Purpose

Hubspot.FollowUpTaskPoll

ScheduledEvent

Poll HubSpot for completed tasks — rules

Hubspot.ContactResolve

ScheduledEvent

Wide-search HubSpot id resolution — rules

Hubspot.BigQueryEngagementSync

ScheduledEvent

Mirror engagements into Activity

Hubspot.EmailClickReEngagement

ScheduledEvent

Email click segment

Hubspot.SupplierAccountReEngagement

ScheduledEvent

Supplier segment

Hubspot.UnattendedMeetingReEngagement

ScheduledEvent

No-show segment

Hubspot.LostDealReEngagement

ScheduledEvent

Lost deal segment

Hubspot.SalesRepCallListRefresh

ScheduledEvent

Rebuild per-rep lists

Hubspot.Five9CallSync

ScheduledEvent

Import Five9 CSV logs

phone-agent

detail-type Model Purpose

Phone.LineIntelPending

ScheduledEvent

Twilio line intelligence

Phone.TaskDeadlineReminder

ScheduledEvent

Slack reminders + escalation

Phone.CampaignTargetReEngagement

ScheduledEvent

Campaign segment

Dispatch and failure

The scheduled path differs from the SQS path in one important way: failures propagate.

def _handle_eventbridge(event, detail_type, context) -> None:
    registration = event_registry.lookup(detail_type)
    if registration is None:
        logger.warning("No handler registered for detail-type %r. Registered types: %s", …)
        return                                   # silent no-op

    try:
        parsed = registration.event_model.model_validate(event)
    except ValidationError as exc:
        logger.error("Event validation failed … Raw event (truncated): %s", json.dumps(event)[:500])
        raise                                    # propagate

    try:
        registration.handler_class().handle(parsed, context)
    except Exception:
        logger.error("Unhandled exception in handler %s:\n%s", …, traceback.format_exc())
        raise                                    # propagate

There is no queue to absorb a failure, so raising is what makes it visible in the function’s error metric. The next tick is the retry.

Most scheduled handlers iterate per row with an inner try/except, so one bad row does not abort the batch — the exception path is for setup failures, not row failures.

Common early returns

Scheduled handlers return early, with a warning and no work, when a precondition is missing:

Condition Handlers affected

BIGQUERY_CREDENTIALS empty

Hubspot.*ReEngagement (three), Hubspot.BigQueryEngagementSync, Scraper.BigQueryContactSync, Phone.CampaignTargetReEngagement

The BigQuery query raised

all BigQuery-backed handlers — logged at ERROR, then return

The view returned no rows

all segment handlers

CRM_SYNC_QUEUE_URL empty

Phone.LineIntelPending, Phone.CampaignTargetReEngagement

SLACK_BOT_TOKEN or SALES_SLACK_CHANNEL_ID empty

Phone.TaskDeadlineReminder

enrichment.amazon.disabled = "true" in configs

SellerDiscovery.Amazon

An early return is not an error. It logs at WARNING or INFO and the invocation succeeds — which is why a handler that silently does nothing is best diagnosed by reading its log line rather than its error metric.

Invoking a scheduled handler manually

aws lambda invoke \
  --function-name morpheus-sam-hubspot-sync \
  --payload '{"detail-type":"Hubspot.ContactResolve","source":"morpheus.hubspot","detail":{}}' \
  --cli-binary-format raw-in-base64-out /dev/stdout

For SellerDiscovery.Amazon, add parameters in detail:

{"detail-type":"SellerDiscovery.Amazon","source":"enrichment.scraper",
 "detail":{"marketplace":"amazon","provider":"smartscout","max_pages":5}}

A manual invocation is production traffic. The re-engagement handlers PATCH live HubSpot contacts; the Five9 sync creates HubSpot call engagements. Prefer a narrow payload (for example Contact.ResolveHubSpotIds with batch_limit: 1) when testing.

Cadences

Live schedule expressions, which rule is enabled, and which are commented out: EventBridge schedules.