Credits Ledger — PMH RPC Contract
PostMTA tracks every email send against a workspace credit balance. The Credits Ledger exposes a four-operation RPC contract — HOLD, INJECT, COMMIT, VOID — that governs how credits are reserved, consumed, and released throughout the message lifecycle.
RPC Contract Overview
The credit system sits between message submission and delivery. It prevents oversending by reserving credits before a message enters the queue, and it ensures credits are returned if a message is rejected, bounced, or voided before handoff.
| Operation | Endpoint | Purpose | Credit Effect |
|---|---|---|---|
HOLD | POST /v1/credits/hold | Reserve credits before enqueueing a message | Deduct from balance (reserved, not spent) |
INJECT | POST /v1/credits/inject | Confirm the message entered the delivery pipeline | Convert hold to spend (committed) |
COMMIT | POST /v1/credits/commit | Mark a hold as spent (delivery confirmed by remote MTA) | Hold converted to spend final |
VOID | POST /v1/credits/void | Release a held credit back to the available balance | Return reserved credits to balance |
HOLD — Reserve Credits
Call HOLD before placing a message into the outbound queue. The request must include the recipient list, from domain, and an idempotency key. If the workspace has insufficient credits, the HOLD fails and the message is rejected.
curl -X POST https://api.postmta.com/v1/credits/hold \
-H "Authorization: Bearer pmta_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key:idem-2026-07-18-tx-abc123" \
-d '{
"workspace_id": "ws_01HXABC123DEF456",
"from_domain": "mail.example.com",
"recipients": [
{"address": "alice@example.com", "metadata": {"user_id": "u_101"}},
{"address": "bob@example.com", "metadata": {"user_id": "u_102"}}
],
"count": 2,
"metadata": {
"campaign_id": "camp_summer_sale",
"template_id": "tpl_receipt_v3"
}
}'A successful HOLD response:
{
"hold_id": "hold_01HXZ9876543210FEDCBA",
"status": "held",
"credits_reserved": 2,
"balance_before": 5000,
"balance_after": 4998,
"expires_at": "2026-07-18T12:00:00Z",
"idempotency_key": "idem-2026-07-18-tx-abc123"
}Hold Expiry
A HOLD reservation expires after 15 minutes if INJECT is not called. When a hold expires, the reserved credits are automatically returned to the available balance. Messages whose holds expire are dead-lettered and must be re-submitted.
INJECT — Enter Delivery Pipeline
Call INJECT once the message has been written to the PostMTA delivery queue. This converts the HOLD from a reserved state to a committed spend and starts the delivery timer. INJECT is called by the PostMTA internal agent — it is rarely invoked directly by operator code.
curl -X POST https://api.postmta.com/v1/credits/inject \
-H "Authorization: Bearer pmta_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"hold_id": "hold_01HXZ9876543210FEDCBA",
"queue_id": "q_01HXOUTBOUND001",
"injected_at": "2026-07-18T10:05:32Z"
}'Response:
{
"inject_id": "inj_01HXINJECT001",
"hold_id": "hold_01HXZ9876543210FEDCBA",
"status": "committed",
"credits_spent": 2,
"balance_after": 4998
}COMMIT — Confirm Delivery
Call COMMIT when the remote MTA accepts the message (SMTP 250). COMMIT finalises the credit spend and removes the hold from the ledger entirely. A committed credit cannot be returned — it is permanent cost.
curl -X POST https://api.postmta.com/v1/credits/commit \
-H "Authorization: Bearer pmta_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"hold_id": "hold_01HXZ9876543210FEDCBA",
"message_id": "msg_01HX5K9P7N3M4Q6R8T2V4W6Y8",
"committed_at": "2026-07-18T10:05:34Z",
"mta_response": "250 OK: queued as abc123"
}'Response:
{
"commit_id": "cmt_01HXCOMMIT001",
"hold_id": "hold_01HXZ9876543210FEDCBA",
"status": "spent",
"credits_spent": 2
}VOID — Release Reserved Credits
Call VOID to return held credits to the available balance. VOID is valid only against HOLD or INJECT states — it cannot void a COMMIted (already-spent) credit. Common reasons: recipient unsubscribed before delivery, duplicate submission detected, or business logic cancellation.
curl -X POST https://api.postmta.com/v1/credits/void \
-H "Authorization: Bearer pmta_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"hold_id": "hold_01HXZ9876543210FEDCBA",
"reason": "recipient_unsubscribed",
"voided_at": "2026-07-18T10:07:00Z"
}'Response:
{
"void_id": "void_01HXVOID001",
"hold_id": "hold_01HXZ9876543210FEDCBA",
"status": "voided",
"credits_returned": 2,
"balance_before": 4998,
"balance_after": 5000,
"reason": "recipient_unsubscribed"
}Wallet Data Model
The workspace credit wallet is a ledger with immutable write entries. Credits are never modified in place — every operation creates a new ledger row.
| Column | Type | Description |
|---|---|---|
workspace_id | TEXT NOT NULL | Workspace that owns this wallet |
credits | INTEGER NOT NULL | Running available balance after this entry was applied |
last_updated | TIMESTAMPTZ NOT NULL | UTC timestamp of the last balance change |
last_operation | TEXT | HOLD | INJECT | COMMIT | VOID | TOPUP | GRANT |
last_operation_id | TEXT | ID of the operation row that produced this balance |
Immutability
The wallet is append-only. The current balance is a projection of the latest row, not a mutable counter. This design makes audit trivial: sum all TOPUP and GRANT rows, subtract all COMMIT rows, and reproduce the current balance from scratch.
Idempotency
Every HOLD, INJECT, COMMIT, and VOID call accepts an Idempotency-Key HTTP header. PostMTA stores the result of each idempotency key for 24 hours. If the same key is submitted twice, the original response is replayed without re-executing the operation.
curl -X POST https://api.postmta.com/v1/credits/hold \
-H "Authorization: Bearer pmta_live_xxxxxxxxxxxxxxxx" \
-H "Idempotency-Key:idem-2026-07-18-campaign-abc001" \
-H "Content-Type: application/json" \
-d '{...same payload...}'If the same idempotency key is submitted again within 24 hours, the response is:
{
"idempotent_replay": true,
"hold_id": "hold_01HXZ9876543210FEDCBA",
"credits_reserved": 2,
"status": "held"
}order-{order_id}-confirmation-{timestamp}. Bad: a random UUID reused across retries. Keys must be 8–256 ASCII characters.Top-Up Mechanisms
There are two paths to add credits to a workspace wallet:
Stripe Purchase (Self-Serve)
Purchase credits directly from the PostMTA dashboard or via the top-up API. Stripe charge completes synchronously; credits appear in the wallet within seconds.
curl -X POST https://api.postmta.com/v1/credits/topup \
-H "Authorization: Bearer pmta_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"amount": 10000,
"stripe_payment_method_id": "pm_card_visa_xxxx",
"autoprolong": false
}'Credits are invoiced at the current per-credit rate for your plan. Volume discounts apply at 50K, 100K, and 500K credit tiers.
Air-Gap Admin Grant
For air-gapped or regulated environments, an operator with admin privileges can grant credits directly without a Stripe transaction. This creates a GRANT ledger entry with an immutable reason string and approver identity.
curl -X POST https://api.postmta.com/v1/credits/grant \
-H "Authorization: Bearer pmta_live_admin_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"workspace_id": "ws_01HXABC123DEF456",
"amount": 50000,
"reason": "promotional_credit_Q3_2026",
"approver": "admin@yourcompany.com",
"metadata": {
"po_number": "PO-2026-789",
"salesrep": "jane.doe@postmta.com"
}
}'Credit Alerts
Configure low-balance alerts in Settings → Credits → Alerts. PostMTA sends a webhook and optional email when the balance falls below a configurable threshold (default: 10% of your last purchase or 1,000 credits, whichever is lower).
Balance Inquiry
curl https://api.postmta.com/v1/credits/balance \
-H "Authorization: Bearer pmta_live_xxxxxxxxxxxxxxxx"Response:
{
"workspace_id": "ws_01HXABC123DEF456",
"balance": 4998,
"reserved": 0,
"spent_this_month": 150002,
"last_topup_at": "2026-07-01T00:00:00Z",
"last_operation": "VOID",
"last_operation_at": "2026-07-18T10:07:00Z"
}