ReferenceMarketing

Marketing

The marketing module (schema marketing) is the half of the parent’s comms service that comms deliberately did not port: a durable drip/campaign runner, audience resolution + enrollment, abandoned-cart detection, and bounce ingestion. It owns the marketing schema and drives sending through comms in-process — never a forked Resend adapter, never a second suppression list.

Four capabilities, each real:

  1. Drip runner (the drip-runner sweep) — finds enrollments whose current step is due, sends that step’s template via comms, advances to the next step, completes at the last. Exactly-once: every step is guarded by a claim on marketing.enrollment_sends (enrollment_id, step_order), so a re-sweep or a crash between send and advance never re-sends a step. The due-scan only picks enrollments whose campaign is active — pausing a campaign stops its sends without losing anyone’s place; resuming continues from exactly where each enrollment stood.
  2. Audience resolution + enrollmentPOST .../campaigns/:id/enroll resolves a campaign’s audience over marketing’s own emailable subjects registry and enrolls the matches.
  3. Abandoned-cart (the abandoned-cart-scan sweep) — the storefront BFF pings POST /cart-activity on cart change (one row per (tenant, cart)); the sweep enrolls carts idle past a threshold into the tenant’s active abandoned-cart campaign, which the drip runner then sends.
  4. Bounce ingestion — the webhooks gateway’s resend provider fans out Resend bounce/complaint deliveries as webhook.received.v1; the consumer writes the bounced address to comms’ suppression list (reused in-process), so every future comms send to it is blocked.

Operator routes

x-operator-token, rate-limited. Mounted at /v1/marketing.

MethodPathPurpose
POST/v1/marketing/campaignsCreate a campaign (tenant_ref in the body). A step’s from_address is optional — a missing one resolves to the store’s default sender.
POST/v1/marketing/campaigns/:id/enrollResolve the campaign’s audience and enroll matches.
POST/v1/marketing/campaigns/:id/pausePause — the drip runner stops claiming this campaign’s steps.
POST/v1/marketing/campaigns/:id/resumeResume — enrollments continue where they left off.
POST/v1/marketing/campaigns/:id/cancelCancel — archives the campaign and ends its active enrollments.
POST/v1/marketing/ops/drip-runner/runOperator-triggered run of the drip sweep.
POST/v1/marketing/ops/abandoned-cart/runOperator-triggered run of the abandoned-cart scan.
GET/v1/marketing/auditOperator audit-log read (makeAuditReadRoute).

Cart-activity ping (dual-auth)

Registered on the root app before the operator sub-app mount, so the operator-only gate does not shadow it. Accepts either the storefront token or the operator token.

MethodPathPurpose
POST/v1/marketing/cart-activityServer-side cart-changed signal from the storefront BFF — one row per (tenant, cart), the input to the abandoned-cart sweep.

Merchant routes

fdk_ credential, tenant from the credential per credential-carries-ownership, scope manage:own_marketing. Mounted at /v1/merchant/marketing — the portal’s campaign builder.

MethodPathScopePurpose
GET/v1/merchant/marketing/campaignsmanage:own_marketingList the caller’s campaigns.
GET/v1/merchant/marketing/campaigns/:idmanage:own_marketingFetch one campaign.
GET/v1/merchant/marketing/campaigns/:id/enrollmentsmanage:own_marketingList a campaign’s enrollments.
POST/v1/merchant/marketing/campaignsmanage:own_marketingCreate a campaign. Templates are the merchant-sendable subset only (welcome, abandoned-cart, abandoned-cart-reminder) — internal platform templates are refused with a 403. Every step sends from the store’s default sender (no-reply@{comms.senderDomain}, else the platform sender); a caller-supplied from_address is ignored.
POST/v1/merchant/marketing/campaigns/:id/enrollmanage:own_marketingEnroll the campaign’s audience.
POST/v1/merchant/marketing/campaigns/:id/pausemanage:own_marketingPause the campaign (drip stops; enrollments keep their place).
POST/v1/merchant/marketing/campaigns/:id/resumemanage:own_marketingResume a paused campaign.
POST/v1/merchant/marketing/campaigns/:id/cancelmanage:own_marketingCancel: archive the campaign and end its active enrollments.

Events

These are internal-only bus events (not fanned out to merchant webhooks); see Events for their payloads.

DirectionEventWhy
Publishesmarketing.campaign.enrolled.v1A person entered a campaign.
Publishesmarketing.message.sent.v1A campaign step actually sent through comms.
Publishesmarketing.message.engaged.v1A provider engagement (delivered/opened/clicked/bounced/complained) landed for a send.
Publishesmarketing.campaign.converted.v1An order was attributed (last-touch) to a campaign.
Consumeswebhook.received.v1Resend/SES bounce + complaint deliveries -> comms suppression, so a bounced address is never emailed again.
Consumescommerce.order.placed.v1Marks a marketing subject has_ordered — the audience filter the drip/broadcast resolver reads.

Manifest gates

None of its own. Abandoned-cart depends on the storefront BFF forwarding the /cart-activity ping (a server-side wiring, not a manifest toggle); campaign sending obeys comms’ suppression and opt-out gates.

Seams (honest notes)

  • Audience segmentation is basicall / has-ordered / created-after filters ship; richer segmentation is the documented seam (it needs a persons list endpoint that does not exist yet).
  • A dead-lettered bounce that can’t be attributed to a tenant is written to a reserved sentinel row for operator reconciliation — not silently suppressed against a guessed tenant.
  • The storefront cart is client-only; the abandoned-cart signal is the minimal server-side row the BFF ping adds, not a full server cart.