Scenario
Your marketing team maintains a Notion content calendar database with columns like Title, Status, Content Type, Target Persona, and Publish Date. Planned pieces sit in “Idea” or “Outlined” status with no draft. This job queries the database for upcoming content, then calls Mavera’s Generate endpoint for each piece — producing first drafts at scale that match your existing brand voice. Flow: NotionPOST /databases/{id}/query (filter: status = “Planned”) → For each row → Mavera POST /api/v1/generations (with brand_voice_id) → Draft content attached back as Notion page
Code
import os, requests, time
NOTION = os.environ["NOTION_API_KEY"]
MV = os.environ["MAVERA_API_KEY"]
NB = "https://api.notion.com/v1"
MB = "https://app.mavera.io/api/v1"
NH = {
"Authorization": f"Bearer {NOTION}",
"Notion-Version": "2022-06-28",
"Content-Type": "application/json",
}
MH = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}
CALENDAR_DB_ID = "your-content-calendar-database-id"
BRAND_VOICE_ID = "bv_your_voice_id"
# 1. Query planned content from the calendar
results = requests.post(f"{NB}/databases/{CALENDAR_DB_ID}/query", headers=NH, json={
"filter": {
"property": "Status",
"select": {"equals": "Planned"},
},
"sorts": [{"property": "Publish Date", "direction": "ascending"}],
"page_size": 20,
}).json().get("results", [])
print(f"Found {len(results)} planned content pieces")
# 2. Generate a draft for each piece
drafts = []
for page in results:
props = page.get("properties", {})
title_parts = props.get("Title", {}).get("title", [])
title = "".join(t.get("plain_text", "") for t in title_parts) or "Untitled"
content_type = (props.get("Content Type", {}).get("select", {}) or {}).get("name", "blog post")
persona = (props.get("Target Persona", {}).get("select", {}) or {}).get("name", "general audience")
notes_parts = props.get("Notes", {}).get("rich_text", [])
notes = "".join(t.get("plain_text", "") for t in notes_parts)
gen = requests.post(f"{MB}/generations", headers=MH, json={
"brand_voice_id": BRAND_VOICE_ID,
"prompt": (
f"Write a {content_type} titled '{title}' for {persona}. "
f"{'Brief: ' + notes if notes else 'No additional brief.'} "
f"Include an engaging intro, 3-5 sections with headers, and a conclusion with CTA. "
f"Length: 800-1200 words."
),
}).json()
draft_text = gen.get("output", gen.get("content", ""))
drafts.append({"title": title, "page_id": page["id"], "draft": draft_text})
# 3. Append draft as a child block in the Notion page
blocks = []
for paragraph in draft_text.split("\n\n"):
paragraph = paragraph.strip()
if not paragraph:
continue
if paragraph.startswith("# "):
blocks.append({"object": "block", "type": "heading_1",
"heading_1": {"rich_text": [{"type": "text", "text": {"content": paragraph[2:]}}]}})
elif paragraph.startswith("## "):
blocks.append({"object": "block", "type": "heading_2",
"heading_2": {"rich_text": [{"type": "text", "text": {"content": paragraph[3:]}}]}})
else:
for chunk in [paragraph[i:i+2000] for i in range(0, len(paragraph), 2000)]:
blocks.append({"object": "block", "type": "paragraph",
"paragraph": {"rich_text": [{"type": "text", "text": {"content": chunk}}]}})
if blocks:
requests.patch(f"{NB}/blocks/{page['id']}/children", headers=NH,
json={"children": blocks[:100]})
# 4. Update status to "Draft"
requests.patch(f"{NB}/pages/{page['id']}", headers=NH, json={
"properties": {"Status": {"select": {"name": "Draft"}}}
})
print(f" ✓ {title} → {len(draft_text)} chars")
time.sleep(0.5)
print(f"\nGenerated {len(drafts)} drafts")
const NOTION = process.env.NOTION_API_KEY;
const MV = process.env.MAVERA_API_KEY;
const NB = "https://api.notion.com/v1";
const MB = "https://app.mavera.io/api/v1";
const NH = {
Authorization: `Bearer ${NOTION}`,
"Notion-Version": "2022-06-28",
"Content-Type": "application/json",
};
const MH = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };
const CALENDAR_DB_ID = "your-content-calendar-database-id";
const BRAND_VOICE_ID = "bv_your_voice_id";
// 1. Query planned content
const results = await fetch(`${NB}/databases/${CALENDAR_DB_ID}/query`, {
method: "POST", headers: NH,
body: JSON.stringify({
filter: { property: "Status", select: { equals: "Planned" } },
sorts: [{ property: "Publish Date", direction: "ascending" }],
page_size: 20,
}),
}).then(r => r.json()).then(d => d.results || []);
console.log(`Found ${results.length} planned content pieces`);
// 2. Generate drafts
const drafts = [];
for (const page of results) {
const props = page.properties || {};
const title = (props.Title?.title || []).map(t => t.plain_text).join("") || "Untitled";
const contentType = props["Content Type"]?.select?.name || "blog post";
const persona = props["Target Persona"]?.select?.name || "general audience";
const notes = (props.Notes?.rich_text || []).map(t => t.plain_text).join("");
const gen = await fetch(`${MB}/generations`, {
method: "POST", headers: MH,
body: JSON.stringify({
brand_voice_id: BRAND_VOICE_ID,
prompt: `Write a ${contentType} titled '${title}' for ${persona}. ` +
`${notes ? "Brief: " + notes : "No additional brief."} ` +
`Include intro, 3-5 sections with headers, conclusion with CTA. 800-1200 words.`,
}),
}).then(r => r.json());
const draftText = gen.output || gen.content || "";
drafts.push({ title, pageId: page.id, draft: draftText });
// 3. Append draft blocks
const blocks = [];
for (const paragraph of draftText.split("\n\n")) {
const p = paragraph.trim();
if (!p) continue;
if (p.startsWith("# ")) {
blocks.push({ object: "block", type: "heading_1",
heading_1: { rich_text: [{ type: "text", text: { content: p.slice(2) } }] } });
} else if (p.startsWith("## ")) {
blocks.push({ object: "block", type: "heading_2",
heading_2: { rich_text: [{ type: "text", text: { content: p.slice(3) } }] } });
} else {
for (let i = 0; i < p.length; i += 2000) {
blocks.push({ object: "block", type: "paragraph",
paragraph: { rich_text: [{ type: "text", text: { content: p.slice(i, i + 2000) } }] } });
}
}
}
if (blocks.length) {
await fetch(`${NB}/blocks/${page.id}/children`, {
method: "PATCH", headers: NH,
body: JSON.stringify({ children: blocks.slice(0, 100) }),
});
}
// 4. Update status
await fetch(`${NB}/pages/${page.id}`, {
method: "PATCH", headers: NH,
body: JSON.stringify({ properties: { Status: { select: { name: "Draft" } } } }),
});
console.log(` ✓ ${title} → ${draftText.length} chars`);
await new Promise(r => setTimeout(r, 500));
}
console.log(`\nGenerated ${drafts.length} drafts`);
Example Output
Found 8 planned content pieces
✓ Q3 Product Launch Blog → 1847 chars
✓ Customer Success Webinar Script → 2103 chars
✓ SEO Guide: AI in Marketing → 1654 chars
✓ Case Study: Acme Corp → 1422 chars
✓ LinkedIn Carousel: 5 Tips → 892 chars
✓ Email Nurture: Onboarding Series #3 → 1238 chars
✓ Whitepaper: ROI Framework → 3201 chars
✓ Social Thread: Product Update → 756 chars
Generated 8 drafts
Error Handling
Database not shared with integration
Database not shared with integration
If the query returns
404 or object not found, open the database in Notion → ⋯ → Connections → add your integration. This is the most common issue.Property name mismatch
Property name mismatch
Notion property names are case-sensitive. If
Status is actually status or Content Status in your database, the filter returns zero results. Use GET /databases/{id} to inspect property names.Rate limit (3 req/sec)
Rate limit (3 req/sec)
The loop includes a 500ms delay. For calendars with 50+ pieces, add exponential backoff on 429 or batch into runs of 10.
Block content limit
Block content limit
Notion limits
rich_text content to 2000 characters per block. The code chunks long paragraphs. Append requests are limited to 100 blocks per call.