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:
- Drip runner (the
drip-runnersweep) — finds enrollments whose current step is due, sends that step’s template viacomms, advances to the next step, completes at the last. Exactly-once: every step is guarded by a claim onmarketing.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. - Audience resolution + enrollment —
POST .../campaigns/:id/enrollresolves a campaign’s audience over marketing’s own emailablesubjectsregistry and enrolls the matches. - Abandoned-cart (the
abandoned-cart-scansweep) — the storefront BFF pingsPOST /cart-activityon 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. - Bounce ingestion — the webhooks gateway’s
resendprovider fans out Resend bounce/complaint deliveries aswebhook.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.
| Method | Path | Purpose |
|---|---|---|
POST | /v1/marketing/campaigns | Create 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/enroll | Resolve the campaign’s audience and enroll matches. |
POST | /v1/marketing/campaigns/:id/pause | Pause — the drip runner stops claiming this campaign’s steps. |
POST | /v1/marketing/campaigns/:id/resume | Resume — enrollments continue where they left off. |
POST | /v1/marketing/campaigns/:id/cancel | Cancel — archives the campaign and ends its active enrollments. |
POST | /v1/marketing/ops/drip-runner/run | Operator-triggered run of the drip sweep. |
POST | /v1/marketing/ops/abandoned-cart/run | Operator-triggered run of the abandoned-cart scan. |
GET | /v1/marketing/audit | Operator 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.
| Method | Path | Purpose |
|---|---|---|
POST | /v1/marketing/cart-activity | Server-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.
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /v1/merchant/marketing/campaigns | manage:own_marketing | List the caller’s campaigns. |
GET | /v1/merchant/marketing/campaigns/:id | manage:own_marketing | Fetch one campaign. |
GET | /v1/merchant/marketing/campaigns/:id/enrollments | manage:own_marketing | List a campaign’s enrollments. |
POST | /v1/merchant/marketing/campaigns | manage:own_marketing | Create 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/enroll | manage:own_marketing | Enroll the campaign’s audience. |
POST | /v1/merchant/marketing/campaigns/:id/pause | manage:own_marketing | Pause the campaign (drip stops; enrollments keep their place). |
POST | /v1/merchant/marketing/campaigns/:id/resume | manage:own_marketing | Resume a paused campaign. |
POST | /v1/merchant/marketing/campaigns/:id/cancel | manage:own_marketing | Cancel: 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.
| Direction | Event | Why |
|---|---|---|
| Publishes | marketing.campaign.enrolled.v1 | A person entered a campaign. |
| Publishes | marketing.message.sent.v1 | A campaign step actually sent through comms. |
| Publishes | marketing.message.engaged.v1 | A provider engagement (delivered/opened/clicked/bounced/complained) landed for a send. |
| Publishes | marketing.campaign.converted.v1 | An order was attributed (last-touch) to a campaign. |
| Consumes | webhook.received.v1 | Resend/SES bounce + complaint deliveries -> comms suppression, so a bounced address is never emailed again. |
| Consumes | commerce.order.placed.v1 | Marks 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 basic —
all/has-ordered/created-afterfilters ship; richer segmentation is the documented seam (it needs apersonslist 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.