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.

On this page
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
EventWhen
message.deliveredPartner send landed in inbox
message.readRecipient marked read
message.read_ackRead-ack delivered to sender
message.dismissedRecipient dismissed
message.reactionEmoji set/cleared (data.emoji)
enrollment.confirmedOpt-in completed
pingTest 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

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.

RoutePurpose
POST /email-otp-sendEmail a 6-digit OTP
POST /registerSign-up / sign-in + balance snapshot
POST /inboxPull pending messages
POST /inbox-ackRead / dismiss / reactions / read-ack
POST /sendClient-lane send (Invite Code required)
POST /outboxNEW — server-side sent list
POST /events/waitNEW — long-poll realtime
POST /attach/uploadNEW — attachment upload
GET /attach?k=NEW — serve attachment bytes
POST /code-create|list|revokeInvite Codes (consent)
POST /connection-request|respondOne-Click Connect
POST /push/register · /register-fcmWeb 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 blasts stay on the E2EE path (voice_ciphertext_*, ≤180s / ~2.85 MB webm/opus) — they are not attachment uploads.

Errors

Uniform shape: { "ok": false, "error": "snake_case_code", … }

CodeMeaning
unauthorized / invalid_api_key / invalid_signatureAuth failed
invalid_employee_tokenSeat revoked or unknown
org_api_key_requiredSeat tried an admin action
recipient_not_enrolled / recipient_revokedConsent missing
insufficient_credits / insufficient_prepaidTop up required
rate_limit_exceededBackoff; see retry_after_seconds
employee_daily_cap_reachedSeat daily cap hit
payload_too_large / invalid_mime_typeAttachment rejected

Need a walkthrough? See code examples or email AL@SavingsSites.com. API use is subject to the API Terms of Service.