Marketing
Lifecycle email: drip sequences that reach the right people.
Also for this capability: API · CLI · MCP · SDKs
Lifecycle email: drip sequences that reach the right people.
| Base URL | https://api.hanzo.ai |
| Operations | 35 |
| Auth | Authorization: Bearer $HANZO_API_KEY |
Specification
HIP-1136 · Marketing — Lifecycle Email — Draft · read the specification →
/v1/marketing is lifecycle email: drip sequences that reach the right people.
Audiences resolve from the IAM roster, promo codes record redemptions an admin
grants against, and every send passes through the per-org suppression list. It
is implemented in hanzoai/cloud at apps/marketing (HIP-0106). The two
invariants this HIP states: there is exactly one send seam, and this capability
can no longer mint money.
Motivation
A marketing surface accumulates senders — a campaign blast here, a drip step
there, a calendar hook — and every sender that bypasses the opt-out list is a
compliance violation waiting on a query. The design forces every delivery
through one function so the suppression check cannot be skipped by
construction (apps/marketing/suppress.go:26-32). The second lesson was paid
for: an earlier promo redemption deposited real spendable wallet credit on any
validated principal's say-so — plan and seat count from the request body,
nothing collecting the charge the discount was against — a self-service money
mint worth about $1.79M at the cap. The deposit is gone
(apps/marketing/promos.go:30-48).
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
§1 The store, and the engine underneath
One encrypted SQLite file, the deployment's own marketing
(apps/marketing/store.go:42); every query filters WHERE org=?. Drip steps
are durable tasks on the embedded hanzoai/tasks engine: each enrollment's
next-run time lives in SQLite, a per-minute schedule sweeps due steps, and
every step is claimed once, so a redeploy neither loses nor duplicates a send
(apps/marketing/marketing.go:15-20).
§2 One send seam
Every marketing delivery — campaign, drip step, calendar email — calls the one
deliver function, which consults the per-org suppression list and then hands
off to the platform notify rail; marketing never constructs a provider and has
no second door out (apps/marketing/suppress.go:138-148). A suppressed
recipient is refused at the seam, and a signed public one-click unsubscribe
writes the same list. A product announcement is not a feature beside this: it
is a one-step sequence with an audience enrolled, inheriting claimed-once
delivery, the suppression gate and the unsubscribe footer
(apps/marketing/marketing.go:42-45).
§3 Audiences are honest
An audience resolves to real mailboxes through the IAM roster; one with an
event filter narrows that roster to the cohort the analytics warehouse
selected. When the roster or warehouse cannot be read the answer is honestly
empty — never a fabricated count, never a send to nobody reported as success
(apps/marketing/marketing.go:18-23).
§4 A redemption is evidence, not money
What a promo redemption produces is a ROW — the org, the server-derived plan,
the discount claimed, and when — which an admin grants against through the
admin surface and the auditable ledger. The plan is derived from the org's live
paid subscription and never accepted from the caller (the input carries no plan
and no seats, so there is no field to inflate); an unreadable plan authority
refuses, inverting the spend gate's fail-open, because failing open here would
let an outage manufacture the evidence money is granted against; the payment
instrument is required as the anti-farming key
(apps/marketing/promos.go:39-60).
§5 The address, tenancy, money, events, observability
Thirty-five operations under /v1/marketing, every one a typed op — one
registry entry projecting REST, the document, the MCP tool and the CLI
(apps/marketing/marketing.go:47-52). The tenant is the org minted from the
validated bearer (HIP-0026), carried to the typed seam by the bridge and read
back server-side — never a header, never an In field. Free (cloud.Free,
plugin/marketing/main.go); the send rail and any granted credit are other
planes' ledgers. It publishes nothing on the bus and emits nothing beyond the
request span every route gets.
§6 Stage and upstream
beta: a vertical application. It derives from no third-party upstream; the
durable engine it composes is hanzoai/tasks (HIP-1062), embedded, not
mirrored here.
Rationale
The alternative to one send seam is a suppression check in every handler, which is the same check N times until one path forgets it. The alternative to deleting the promo deposit was gating it harder — but an automatic path that creates money is not a feature to fix, it is a mechanism to remove, because a money mint left switched off is one flag away from switched on.
Security Considerations
The wrong implementation here is a spam cannon with a ledger attached. Send authority: any path around the deliver seam bypasses opt-out, so the seam is the only door and tests assert exactly which recipients reach the rail. Money: the removed deposit is the standing lesson — a redemption input that named its own plan converted open signup into self-served credit; the current shape has no field to inflate and fails closed on an unreadable authority. Tenancy: the suppression list, audiences and enrollments are all org-column scoped on the validated principal, so one tenant can neither read another's roster nor unsubscribe another's customers.
Four surfaces
| Surface | Reaches this capability as | Coverage |
|---|---|---|
| REST | marketing at its own prefix | 35 operations |
| CLI | hanzo marketing … | 35 of 35 |
| SDK | MarketingApi in every published client | 35 methods |
| MCP | tool marketing on https://api.hanzo.ai/v1/mcp | 35 operations, 4 under the document's own id — ask describe for the rest |
Quickstart
export HANZO_API_KEY=sk-... # console.hanzo.ai → API keysThen the first call — a read that needs nothing but the key. GET /v1/marketing/promos, operation get_marketing_promos:
hanzo marketing promos listimport { Configuration, MarketingApi } from 'hanzoai';
const api = new MarketingApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getMarketingPromos();from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import MarketingApi
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = MarketingApi(client).get_marketing_promos()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.MarketingAPI.GetMarketingPromos(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::apis::{configuration::Configuration, marketing_api};
let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
let result = marketing_api::get_marketing_promos(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.MarketingApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new MarketingApi(client).getMarketingPromos();curl https://api.hanzo.ai/v1/marketing/promos \
-H "Authorization: Bearer $HANZO_API_KEY"MCP reaches marketing through the marketing tool, which names its 35 operations with its own verbs — this one among them, under a name only MCP declares. describe explains any of them:
curl -X POST https://api.hanzo.ai/v1/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "describe",
"arguments": {
"op": "list_marketing_audiences"
}
}
}'Answers 200 with object — ok.
Endpoints
| Endpoint | What it does |
|---|---|
GET /v1/marketing/audiences/{id}/preview | Evaluates the cohort LIVE — the same resolution an enrollment would run — and reports how big it is and how many real mailboxes it reaches. |
GET /v1/marketing/audiences/{id} | Returns one of the caller org's saved audiences. |
DELETE /v1/marketing/audiences/{id} | Removes one of the caller org's audiences and answers 204. |
GET /v1/marketing/audiences | Returns the org's saved audiences, most recently updated first. |
POST /v1/marketing/audiences | Saves a cohort filter for the caller's org. |
POST /v1/marketing/calendar/{id}/publish | Publishes a post NOW, synchronously, whatever its schedule. |
GET /v1/marketing/calendar/{id} | Returns one of the caller org's posts, including the exact error behind a failed publish. |
PUT /v1/marketing/calendar/{id} | Replaces a post's editable fields. |
DELETE /v1/marketing/calendar/{id} | Removes one of the caller org's posts and answers 204. |
GET /v1/marketing/calendar | Returns the org's calendar, latest scheduled first, optionally narrowed to one status. |
POST /v1/marketing/calendar | Adds a post to the content calendar. |
POST /v1/marketing/campaigns/{id}/schedule | Sets a campaign's send time and moves it to "scheduled". |
GET /v1/marketing/campaigns/{id} | Returns one of the caller org's campaigns. |
PUT /v1/marketing/campaigns/{id} | Replaces a campaign's editable fields. |
DELETE /v1/marketing/campaigns/{id} | Removes one of the caller org's campaigns and answers 204. |
GET /v1/marketing/campaigns | Returns the org's campaigns, most recently updated first, optionally narrowed to one lifecycle status. |
POST /v1/marketing/campaigns | Registers a campaign in the caller's org. |
GET /v1/marketing/promos/{code}/eligibility | Prices a promo against a plan and seat count. |
POST /v1/marketing/promos/{code}/redeem | Records the caller org's claim on a promo. |
GET /v1/marketing/promos/{code}/redemption | Returns the caller org's OWN redemption of a promo — an org-scoped read, so it can never surface another tenant's. |
GET /v1/marketing/promos | Returns every promo the deployment offers with its live counters: how many orgs have redeemed it and how many redemptions remain under the cap. |
POST /v1/marketing/sequences/{id}/enroll | Adds one contact or a whole audience to a sequence and schedules the first step for each. |
POST /v1/marketing/sequences/{id}/enrollments/{eid}/cancel | Stops one walk mid-sequence and answers 204: no further step is sent, and steps already delivered are not recalled. |
GET /v1/marketing/sequences/{id}/enrollments | Returns who is walking one sequence, most recently enrolled first, with each walk's current step and next due time. |
POST /v1/marketing/sequences/{id}/status | Flips draft/active/archived — the activation gate for sending, since only an active sequence accepts enrollments. |
GET /v1/marketing/sequences/{id}/steps | Returns one sequence's steps in send order. |
POST /v1/marketing/sequences/{id}/steps | Appends a message to the END of a sequence: the new step's idx is one past the last, so steps arrive in the order they are added. |
GET /v1/marketing/sequences/{id} | Returns one of the caller org's sequences together with its steps in send order. |
GET /v1/marketing/sequences | Returns the org's drip sequences, most recently updated first. |
POST /v1/marketing/sequences | Registers a drip sequence in the caller's org. |
GET /v1/marketing/summary | Rolls up the caller org's campaigns: how many there are, how many are active, and the summed budget and spend in cents. |
GET /v1/marketing/suppressions | Returns the org's opt-out list, newest first — everyone the send gate will refuse to deliver to. |
POST /v1/marketing/suppressions | Records an opt-out for the org (admin / self-service management). |
DELETE /v1/marketing/suppressions | Re-subscribes an address on one channel and answers 204. |
GET /v1/marketing/unsubscribe | Is the PUBLIC one-click endpoint (no principal): a recipient clicks the signed link in an email footer. |
How is this guide?