API Reference
Production origin: https://api.blasts.app (legacy alias: https://b2b-leads-licenses.netlify.app) — Partner prefix /api/v1/partner, Client prefix /api/ping. Always branch on ok in the JSON body — some legacy routes return HTTP 200 with ok:false.
Partner API Auth Employees Send Batch Campaigns Outbox Webhooks Deliveries Enrollment Client API Realtime Attachments Errors Rate limits →
Partner API
Server-to-server lane for enterprise senders (CRM, SaaS, logistics). Prefix: /api/v1/partner.
Authentication
Mode A — Org key + HMAC (full trust: seats, webhooks, enrollments)
Authorization: Bearer pk_live_… ← live
Authorization: Bearer pk_test_… ← sandbox (same HMAC headers)
X-Blasts-Timestamp: 1717852800000 ← ms since epoch, ±5 min skew
X-Blasts-Signature: sha256=<hex> ← HMAC-SHA256(secret, `${ts}\n${rawBody}`)
Idempotency-Key: optional-unique-key ← send retries
For GET requests the raw body is the empty string. Do not mix modes: a pk_test_ key only works on a sandbox partner; pk_live_ only on live.
Sandbox (pk_test_*): 0 credits per send, body prefixed with [SANDBOX], hard cap 100 sends per UTC day (shared across single + batch + campaign). Over cap → sandbox_daily_cap_exceeded (429). Ask Tips Marketing for a sandbox partner, or admin POST /api/admin/partners/create-sandbox.
Mode B — Employee seat token (native sender consoles — no org secret in the binary)
Authorization: Bearer emp_live_… ← no HMAC required Content-Type: application/json
Seat tokens can call /messages/send and /messages. They cannot manage seats, webhooks, or /messages/batch (org_api_key_required).
GETPOSTDELETE /employees
Org-admin only. Mint / list / revoke per-employee seats.
POST /api/v1/partner/employees
{ "label": "Dave — macOS console", "daily_send_cap": 200, "external_ref": "okta:dave" }
→ { "ok": true,
"employee": { "employee_id": "emp_…", "label": "…", "daily_send_cap": 200, "status": "active" },
"employee_token": "emp_live_…", ← shown ONCE
"warning": "Store employee_token securely…" }
DELETE body: { "employee_id": "emp_…" }. GET returns the seat list (no tokens).
POST /messages/send
{
"to_email": "customer@example.com",
"body_plain": "Your package was delivered at 2:14 PM.",
"body_html": "<p><strong>Delivered</strong></p>",
"priority": "urgent",
"request_read_ack": true,
"attachments": [{ "url": "https://…/api/ping/attach?k=…", "mime": "image/jpeg", "filename": "pod.jpg", "size": 48211 }],
"metadata": { "tracking_number": "1Z999…" }
}
Recipient must already be enrolled (active pairing). Debits 1 partner credit. Returns message_id (partner id) + transport_message_id (inbox id).
POST /messages/batch
Org API key + HMAC only — employee seat tokens are rejected. Max 500 recipients per request · 10 batch req/min/partner. Unenrolled recipients are skipped (counted). Sends run async via a background worker. Merge tags: {{name}} from each row’s merge object.
{
"batch_name": "fedex_sunday_digest",
"body_plain": "Hi {{name}}, update on {{tracking}}.",
"body_html": "<p>Hi {{name}}, …</p>",
"priority": "normal",
"recipients": [
{ "to_email": "a@example.com", "merge": { "name": "Ann", "tracking": "1Z…" } },
{ "to_email": "b@example.com", "merge": { "name": "Bob", "tracking": "1Y…" } }
]
}
→ { "ok": true, "batch_id": "pbatch_…", "status": "queued", "queued": 2, "skipped": 0 }
POST /campaigns · GET /campaigns/{campaign_id}
Org API key + HMAC only. Named wrapper around the same async batch worker (same 500 / 10-per-min limits). Credits debit once per successful recipient — not twice. Poll GET for sent · failed · skipped · status.
POST /api/v1/partner/campaigns
{
"campaign_name": "spring_digest",
"body_plain": "Hi {{name}} — your update.",
"recipients": [
{ "to_email": "a@example.com", "merge": { "name": "Ann" } }
]
}
→ { "ok": true, "campaign_id": "pcamp_…", "batch_id": "pbatch_…", "status": "queued", "queued": 1, "sent": 0 }
GET /api/v1/partner/campaigns/pcamp_…
→ { "ok": true, "status": "completed", "sent": 1, "failed": 0, "skipped": 0 }
GET /messages · /messages/{message_id}
Server-side outbox pull. Query: ?limit=25&cursor=0. Status values: delivered · read · dismissed · acknowledged.
GETPOSTDELETE /webhook
Configure a signed outbound webhook. Org key+HMAC only.
POST /api/v1/partner/webhook
{ "url": "https://your.app/hooks/blasts", "events": ["message.read","message.dismissed","enrollment.confirmed"] }
→ { "ok": true, "webhook": {…}, "webhook_secret": "whsec_…" } ← shown ONCE
POST { "test": true } → fires a signed ping event
Verify every delivery:
signed = `${X-Blasts-Webhook-Timestamp}.${rawBody}`
expected = hex(hmac_sha256(webhook_secret, signed))
assert timingSafeEqual(expected, X-Blasts-Webhook-Signature.replace('sha256=',''))
assert abs(now - timestamp) < 5 minutes
| Event | When |
|---|---|
message.delivered | Partner send landed in inbox |
message.read | Recipient marked read |
message.read_ack | Read-ack delivered to sender |
message.dismissed | Recipient dismissed |
message.reaction | Emoji set/cleared (data.emoji) |
enrollment.confirmed | Opt-in completed |
ping | Test only |
Delivery: one immediate attempt (5s); retries hourly with backoff, max 5, then drop. HTTPS only; no localhost / IP literals (SSRF guard). Dedupe on X-Blasts-Event-Id.
GET /webhook/deliveries
Org API key + HMAC only. Debug log of recent outbound attempts (last 100 kept; newest first). Never returns webhook_secret.
GET /api/v1/partner/webhook/deliveries?limit=50
→ {
"ok": true,
"count": 1,
"deliveries": [
{ "event_id": "evt_…", "type": "ping", "http_status": 200, "ok": true, "attempt": 1, "at": "…", "last_error": null }
]
}
Enrollment
- POST
/enrollment/links— mint hosted URL → blasts.dev/enroll/… - GET / POST
/enrollment/redeem— public preview + one-time redeem (magic link or paste code; no API key) - POST
/enrollment/confirm— partner server-side confirm (org key + HMAC) - GET
/enrollment/status?email= - POST
/templates/validate— lint HTML without sending - GET
/me— verify credentials / quota
Client API
Same stack the Chrome / Edge extensions use. Auth = machine_id (64 hex) + session_token in the JSON body. Prefix: /api/ping.
OpenAPI: client-v1.yaml · Partner (separate): partner-v1.yaml · Narrative: repo CLIENT_API_FOR_NATIVE_APPS.md.
| Route | Purpose |
|---|---|
POST /email-otp-send | Email a 6-digit OTP |
POST /register | Sign-up / sign-in + balance snapshot |
POST /inbox | Pull pending messages |
POST /inbox-ack | Read / dismiss / reactions / read-ack |
POST /send | Client-lane send (Invite Code required) |
POST /outbox | NEW — server-side sent list |
POST /events/wait | NEW — long-poll realtime |
POST /attach/upload | NEW — attachment upload |
GET /attach?k= | NEW — serve attachment bytes |
POST /code-create|list|revoke | Invite Codes (consent) |
POST /connection-request|respond | One-Click Connect |
POST /push/register · /register-fcm | Web Push / FCM doorbells |
Near-realtime
Netlify Functions cannot host persistent WebSockets. Use the revision long-poll:
POST /api/ping/events/wait
{ "recipient_email": "…", "machine_id": "…", "session_token": "…",
"since_rev": 0, "wait_ms": 20000 }
→ { "ok": true, "rev": 12, "changed": true, "transport": "long_poll" }
On changed: true → immediately POST /inbox, then reconnect wait with the new rev. Keep Web Push / FCM registered for background wake.
Client outbox
POST /api/ping/outbox
{ "from_email": "…", "machine_id": "…", "session_token": "…", "limit": 25, "cursor": "0" }
→ { "ok": true, "messages": […], "next_cursor": "25", "total": 80 }
Attachments
POST /api/ping/attach/upload
{ "from_email": "…", "machine_id": "…", "session_token": "…",
"mime_type": "image/png", "filename": "pod.png", "media_b64": "…" }
→ { "ok": true, "attachment_url": "https://…/api/ping/attach?k=…", "mime": "image/png", "size": 12345 }
Allowed: image/jpeg|png|gif|webp, application/pdf, text/plain. Max 5 MB · 40 uploads/day/sender · 5 per send. Stamp into /send or partner /messages/send as attachments: [{ url, mime, filename, size }].
voice_ciphertext_*, ≤180s / ~2.85 MB webm/opus) — they are not attachment uploads.
Errors
Uniform shape: { "ok": false, "error": "snake_case_code", … }
| Code | Meaning |
|---|---|
unauthorized / invalid_api_key / invalid_signature | Auth failed |
invalid_employee_token | Seat revoked or unknown |
org_api_key_required | Seat tried an admin action |
recipient_not_enrolled / recipient_revoked | Consent missing |
insufficient_credits / insufficient_prepaid | Top up required |
rate_limit_exceeded | Backoff; see retry_after_seconds |
employee_daily_cap_reached | Seat daily cap hit |
payload_too_large / invalid_mime_type | Attachment rejected |
Need a walkthrough? See code examples or email AL@SavingsSites.com. API use is subject to the API Terms of Service.