One List Routing
Email + Screen Blasts from one upload. Send us any contact list. Contacts already connected to you get an encrypted Screen Blast — delivered straight to their screen, no spam folder in the path. Everyone else routes to email you send yourself, with a hosted invite link merged in so each send grows your owned screen channel. One call decides, one price covers everything.
Pricing Auth & idempotency Step 1 — Free preview Step 2 — Routing plan Step 3 — Send Failures & retries Conversion webhooks Sandbox onboarding Quickstart
Pricing — one number, all-in
1 Blasts Credit per contact, billed once at POST /routing/plan. That single fee covers the enrollment lookup, the routing decision, the invite mint for unconnected contacts, and the Screen Blast send for connected ones (prepaid into the plan). There is no separate per-message charge for plan-covered screen sends and no fee of ours on the email side — email goes out through your own sender, at your cost, which for most senders is near zero.
- Preview is free.
POST /enrollment/status/bulknever bills — check your connected/unconnected split as often as you like inside the fair-use ceilings below. - Skip rows are never billed. Invalid addresses and duplicates come back as channel
skipat no charge; you pay only forprocessedrows. - Credits are prepaid and never expire. Same Blasts Credits wallet as the rest of the platform; a refused debit (
402) mints nothing and leaks nothing. - Sandbox is zero-cost (
pk_test_keys) — the response reportscredits.sandbox: true.
Auth & idempotency
Both routing endpoints are Org API key + HMAC only — employee seat tokens are rejected (org_api_key_required).
Authorization: Bearer pk_live_… ← or pk_test_ (sandbox)
X-Blasts-Timestamp: 1717852800000 ← ms since epoch, ±5 min skew
X-Blasts-Signature: sha256=<hex> ← HMAC-SHA256(secret, `${ts}\n${rawBody}`)
Idempotency-Key: plan-2026-08-31-run1 ← MANDATORY on /routing/plan
Idempotency-Key header is mandatory on /routing/plan (a request without one is refused with 400 idempotency_key_required — nothing billed). A replay with the same key returns the first plan's stored result and never re-debits or re-mints. A retry with a different key is a new plan and bills again by design — that is the platform-wide retry-billing rule, so reuse the same key when you retry.
Your partner record needs a provisioned platform_sender (the From address your emails use) before either endpoint answers — otherwise every lookup would silently read "unenrolled." Without one you get 409 platform_sender_required; email AL@SavingsSites.com to set it.
Step 1 — preview the split Free
POST /api/v1/partner/enrollment/status/bulk
Up to 500 emails per request. Returns, per email, the same partner-scoped "enrolled" answer /messages/batch accepts on (active enrollment and live pairing code) — so the preview and the send lane can never disagree. Never an existence oracle: it answers only about enrollments with your partner account, nothing about whether an address exists on the platform.
{ "emails": ["ann@example.com", "bob@example.com"] }
→ {
"ok": true,
"partner_id": "yourco",
"requested": 2,
"processed": 2,
"invalid_count": 0,
"results": [
{ "email": "ann@example.com", "enrolled": true, "status": "active",
"external_user_id": "crm_1041", "enrolled_at": "2026-07-02T18:20:11Z" },
{ "email": "bob@example.com", "enrolled": false, "status": "none",
"external_user_id": null, "enrolled_at": null }
]
}
status values: active · revoked · pending · none. Fair use: your partner-wide request limit (default 100 req/min) plus a free-lane ceiling of 2,000,000 records per UTC day (default, raisable per partner — refused requests never consume ceiling budget; over it → 429 lookup_records_per_day_exceeded).
Step 2 — the routing plan 5¢/1,000
POST /api/v1/partner/routing/plan
One call runs the whole doctrine over up to 500 recipients and returns one row per input, in input order:
| Channel | Meaning | What you do |
|---|---|---|
screen | Connected — their Screen Blast is prepaid by this plan | Send via /messages/batch with this plan_id; no second debit |
email + invite_included: true | Not connected — a hosted 24-hour invite link was minted | Merge invite_url into the email you send from your own sender |
email + invite_included: false | Previously revoked you — a plan never resurrects a revoke (or mint_invites: false) | Email them normally; no invite |
skip | Invalid address or duplicate row — never billed | Fix the row or ignore |
POST /api/v1/partner/routing/plan
Idempotency-Key: spring-digest-2026-08-31
{
"recipients": [
{ "to_email": "ann@example.com", "external_user_id": "crm_1041" },
{ "to_email": "bob@example.com" },
{ "to_email": "not-an-email" }
],
"mint_invites": true
}
→ {
"ok": true,
"plan_id": "plan_7f3c…",
"partner_id": "yourco",
"requested": 3,
"processed": 2,
"summary": { "screen": 1, "email_invite": 1, "email_no_invite": 0, "skip": 1,
"owned_share_pct": 50 },
"rows": [
{ "to_email": "ann@example.com", "channel": "screen", "invite_included": false },
{ "to_email": "bob@example.com", "channel": "email", "invite_included": true,
"invite_url": "https://blasts.dev/enroll/…", "invite_expires_at": "2026-09-01T18:00:00Z" },
{ "to_email": "not-an-email", "channel": "skip", "invite_included": false,
"skip_reason": "invalid_email" }
],
"credits": { "debited": 2, "balance_after": 99998 },
"duplicate": false
}
Privacy invariants: no pairing code ever appears in any response; the enrollment pairing is created only when the recipient redeems the invite. duplicate: true marks an idempotent replay. owned_share_pct is your campaign health number — the percent of processed contacts already on your owned screen channel.
Step 3 — send both halves
Screen rows → /messages/batch with plan_id
{
"batch_name": "spring_digest_screen",
"plan_id": "plan_7f3c…",
"body_plain": "Hi {{name}} — your update.",
"recipients": [ { "to_email": "ann@example.com", "merge": { "name": "Ann" } } ]
}
Screen recipients prepaid by the plan skip the per-message debit — each prepaid slot is honored exactly once, and a failed prepaid send releases its slot for the retry. The plan_id must belong to you (404 otherwise). Recipients in the batch who aren't covered by the plan bill normally (1 partner credit each).
Email rows → your own sender
Send from your own ESP / SES / mailboxes as usual and merge each row's invite_url into the message ("Get these on your screen next time"). We charge nothing on this half; when a recipient redeems the invite, they become screen on your next plan — the list upgrades itself with every send.
Failures & retries — fail-closed, refund-on-fault
The routing lane refuses before it charges, and refunds when a fault lands after the debit. Retry 503s (and 500 plan_failed) with the same Idempotency-Key — that is always safe:
| Status | Code | What happened / what to do |
|---|---|---|
| 400 | idempotency_key_required · missing_recipients · too_many_recipients · no_valid_recipients · malformed_json | Request shape problem — nothing billed. Fix and resend. |
| 402 | insufficient_credits | Refused whole — required_credits + balance returned; nothing minted, nothing leaked. Top up and retry (same key). |
| 403 | org_api_key_required · partner_inactive | Wrong credential class or deactivated partner. |
| 409 | platform_sender_required · plan_replay_unavailable | No sender provisioned; or the idempotency marker exists but the stored plan is genuinely absent — retry with a fresh key (the one case where a fresh key is correct). |
| 429 | rate_limit_exceeded · lookup_records_per_day_exceeded | Back off per retry_after_seconds. |
| 500 | plan_failed | A storage fault landed after the debit — the debit was refunded and the idempotency claim released. Retry with the SAME key; it re-runs exactly once. |
| 503 | consent_unavailable · debit_unavailable · plan_replay_check_failed | A storage fault interrupted a pre-charge check — refused BEFORE any charge or mint. Retry with the SAME key; do not switch keys. |
On the send step, /messages/batch 503s (plan_lookup_failed · batch_save_failed · worker_kick_failed) are likewise retryable: every just-claimed prepaid slot was released before the error surfaced, so the retry rides prepaid again — nothing sent, nothing charged twice.
Conversion webhooks
The enrollment.confirmed webhook event carries plan_id — the routing plan whose invite link that conversion redeemed (null for enrollments outside a plan). That closes the attribution loop: plan → invite → redeem → next plan routes them screen. Configure webhooks per the API Reference.
Sandbox onboarding
Everything above works identically on a sandbox partner: mint pk_test_ keys in the Developer Console (email sign-in, up to 3 per developer, instant). Sandbox lanes bill 0 credits, prefix delivered bodies with [SANDBOX], and cap at 100 sends per UTC day. Prove the full loop — preview → plan → batch with plan_id → webhook — before requesting live keys (human-reviewed, same console).
Quickstart — the whole loop in one script (Node 18+)
const crypto = require('crypto');
const KEY = process.env.BLASTS_API_KEY; // pk_test_… to start
const SECRET = process.env.BLASTS_SIGNING_SECRET;
const BASE = 'https://api.blasts.app/api/v1/partner';
async function call(path, body, extraHeaders = {}) {
const raw = body === undefined ? '' : JSON.stringify(body);
const ts = Date.now().toString();
const sig = crypto.createHmac('sha256', SECRET).update(`${ts}\n${raw}`).digest('hex');
const res = await fetch(BASE + path, {
method: 'POST',
headers: {
'Authorization': `Bearer ${KEY}`,
'X-Blasts-Timestamp': ts,
'X-Blasts-Signature': `sha256=${sig}`,
'Content-Type': 'application/json',
...extraHeaders
},
body: raw
});
return res.json();
}
(async () => {
// 1. FREE preview
const preview = await call('/enrollment/status/bulk',
{ emails: ['ann@example.com', 'bob@example.com'] });
console.log('connected:', preview.results.filter(r => r.enrolled).length);
// 2. The plan — 5¢/1,000, all-in. Idempotency-Key is REQUIRED.
const plan = await call('/routing/plan',
{ recipients: [{ to_email: 'ann@example.com' }, { to_email: 'bob@example.com' }] },
{ 'Idempotency-Key': 'quickstart-run-1' });
console.log(plan.summary, plan.credits);
// 3a. Screen half — prepaid by the plan
const screenRows = plan.rows.filter(r => r.channel === 'screen');
if (screenRows.length) {
await call('/messages/batch', {
batch_name: 'quickstart_screen',
plan_id: plan.plan_id,
body_plain: 'Hello from the routing quickstart.',
recipients: screenRows.map(r => ({ to_email: r.to_email }))
});
}
// 3b. Email half — send from YOUR OWN sender, merging r.invite_url in.
for (const r of plan.rows.filter(x => x.channel === 'email')) {
console.log('email', r.to_email, r.invite_included ? r.invite_url : '(no invite — revoked)');
}
})();
partner-v1.yaml (browse it in Redoc). Where prose and spec disagree, the spec wins.
Questions or live-key requests: AL@SavingsSites.com. API use is subject to the API Terms of Service.