Last Updated: 2026-06-28 Status: Active Companion docs: architecture.md · blog-service.md · permissions.md
1. What Is Billed
Publishing a post costs 1 credit.
The credit is charged at the moment POST /admin/posts/:id/publish is called and succeeds. No other blog operations (create, update, delete, schedule, list, fetch) cost credits.
2. Credit Guard Middleware
The creditGuard middleware wraps the publish handler and manages the full charge/refund lifecycle:
POST /admin/posts/:id/publish
├── creditGuard (before)
│ └── CreditLedger.charge(tenantId, reason="blog.post.publish", amount=1)
│ ├── OK → continue to handler
│ └── Insufficient → 402 Payment Required (handler never runs)
├── Publish handler
│ ├── Already published? → set c.set("creditGuardRefund", true) → middleware refunds
│ ├── Success → 200 { status: "published" }
│ └── Error → 4xx/5xx → middleware auto-refunds
└── creditGuard (after)
├── creditGuardRefund = true → refund (idempotency: post was already published)
└── handler returned 4xx/5xx → refundIdempotency
If a post is already status=published when the publish endpoint is called:
- The handler detects it and sets
c.set("creditGuardRefund", true) - The credit guard automatically issues a full refund
- The caller receives
200 { status: "published" }with no net credit change
This means calling publish twice on an already-published post is safe — the second call costs nothing.
3. 30-Minute Grace Window (Unpublish Refund)
Unpublishing a post within 30 minutes of publish triggers an automatic credit refund.
POST /admin/posts/:id/unpublish
├── Find most recent credit_transactions row WHERE reason = "blog.post.publish" AND post_id = :id
├── Is it within 30 minutes of now?
│ ├── Yes → refund 1 credit (insert reverse transaction)
│ └── No → no refund (content existed publicly, was indexed, newsletter sent)
└── Set status = "draft", clear published_atAfter the grace window, unpublishing is free but does not recover the publish credit.
Edge case — multiple publishes in the grace window: Only the most recent publish transaction is refunded. If a post was published, unpublished, and re-published within 30 minutes, only the last publish transaction is eligible for a refund on the second unpublish.
4. MCP Differences
The MCP publish-post and unpublish-post tools behave differently from the REST endpoints:
| Operation | REST endpoint | MCP tool |
|---|---|---|
publish |
Charges credit ✅ + fans out newsletter ✅ | Charges credit ✅ + no newsletter ❌ |
unpublish |
Grace-window refund ✅ | No refund ❌ |
IMPORTANT
If you need full publish behavior (credit charge + newsletter fan-out), use the REST endpoint (POST /admin/posts/:id/publish). The MCP tool is designed for agent-driven content management where newsletter dispatch should be a separate explicit step.
5. Core Database Connection
Credit operations read and write the core database (CORE_DATABASE_URL), a separate Neon connection from the blog database (BLOG_DATABASE_URL):
| Table | Used for |
|---|---|
tenant_credits |
Read: balance check before publish |
credit_transactions |
Write: charge on publish; write: refund on grace-window unpublish |
subscriptions |
Read: check subscription is active (also done by gateway) |
CORE_DATABASE_URL is required for publish to work. If it is missing or unreachable, the credit guard will fail and publish will return an error.
6. Reason Strings
Credit transactions are tagged with reason = "blog.post.publish" (defined in @repo/core-billing/src/reasons.ts). These reason strings are used by:
- The analytics dashboard to show monthly blog credit spend
- The refund logic to find the matching transaction on unpublish
- Billing reports and invoices
7. Error Codes
| Status | When |
|---|---|
402 Payment Required |
Tenant has insufficient credits to publish |
403 Forbidden |
Missing blog:posts.publish permission |
409 Conflict |
Post is in a state that cannot be published (e.g., already published — but this is handled idempotently; only hard conflicts return 409) |