01 — OVERVIEWThe flow in one paragraph
Your user pairs their phone with the Blasts app once. You call /connect with their email — their phone shows “Your Business wants to use Blasts sign-in approvals,” and they tap Allow. That consent is signed by a key that never leaves their phone.
From then on, whenever they sign in at your site you call /request, show the returned six-digit code on your page, and the user types it into the approval card on their phone. You poll /status until it reports approved.
A business the user hasn't consented to cannot ring their phone even once — the request is refused before it exists. And the code travels through the user's eyes, never through a push payload, so there is nothing for a remote attacker to spam an “Approve?” prompt against.
02 — AUTHCredentials and request signing
Your partner record is provisioned with login_enabled: true. You receive, once:
api_key—pk_live_…(Bearer token)signing_secret—sk_live_…(HMAC key — store it like a password)
Sandbox keys (pk_test_ / sk_test_) mint instantly in the Developer Console — sign in with your email and create up to three. Sandbox ceremonies ring only your own phone (the account that minted the keys), so you can test the full loop without touching anyone else. Live keys are requested from the same console and human-reviewed; rotation is one click, with the old key + secret honored for 24 hours while you swap.
Every call carries three headers
Authorization: Bearer <api_key>
X-Blasts-Timestamp: <epoch milliseconds>
X-Blasts-Signature: sha256=<hex HMAC-SHA256 of "<timestamp>\n<raw body>" keyed with signing_secret>
Timestamps older than five minutes are refused. Sync your clock.
The signature covers the exact raw body bytes you send — sign the serialized string, not a re-serialized object, or the hash won't match.
Employee seat tokens (emp_live_…) are not accepted on login routes. Organization key only.
03 — ENDPOINTConnect a user to your business
POST/api/v1/partner/login/connect
{ "user_email": "customer@example.com", "external_user_id": "your-internal-id-optional" }
The invitation is filed — or the email isn't a Blasts user, or one is already pending. You are deliberately not told which. Tell your user: “Install the Blasts app, pair your phone, then approve the connection card.”
This user already granted you. You can call /request immediately.
The uniform invited response is intentional: this API cannot be used to test which email addresses have Blasts accounts. Enumeration is closed by design, not by rate limit.
Invitations expire after seven days. One is live at a time per user — repeat calls don't re-ring the phone. If a user has blocked you, calls still return invited and nothing happens; only the user can unblock, from their own phone's settings.
04 — ENDPOINTStart a sign-in ceremony
POST/api/v1/partner/login/request
{ "user_email": "customer@example.com",
"origin": "https://login.yoursite.com",
"method": "typed_match_6" }
Success:
{ "ok": true, "request_id": "…", "method": "typed_match_6",
"challenge": "483920", "poll_secret": "…", "expires_at": 1753731600000 }
Show challenge on your page — “Type this code into the Blasts app on your phone.” The code travels through the user's eyes only. Our servers never push it to the phone — so there is no push prompt for a remote attacker to spam, and nothing code-bearing to intercept in transit.
Failure shapes
| Error | Meaning |
|---|---|
| auth_request_failed | Deliberately uniform. Covers no-such-user, not-connected, and opted-out identically — you cannot distinguish them, by design. |
| rate_limited | Three requests per user, per business, per five minutes. |
| cooldown | The user recently denied a request. Treat this as a red flag, not a retry loop. |
method is optional and defaults to typed_match_6 — recommended, and the only method enabled until your organization's admin explicitly enables weaker ones and records the risk acknowledgment.
05 — ENDPOINTPoll for the verdict
POST/api/v1/partner/login/status
{ "request_id": "…", "poll_secret": "…" }
Returns status as one of pending, approved, denied, failed, or expired. Poll every 2–3 seconds until terminal; requests expire after five minutes.
Only your business can see your requests' outcomes — the poll secret alone is not enough for anyone else.
The account's one bound phone produced a signature, with its device-resident key, over this exact request's nonce and the code your page displayed. The response also carries a signed assertion — verify it, then let the user in (section 08).
The user said “this isn't me.” Treat it as an incident, not a retry.
06 — UXWhat your user experiences
- Once: installs the Blasts app, pairs their phone, taps Allow on your connection card.
- Every sign-in: your page shows a six-digit code → their phone shows the approval card with your business name → they type the code → your page unlocks. Typically faster than waiting for an SMS.
- Any time: Settings → Sign-In Approvals → Disconnect or Block. From that moment your
/requestcalls fail — revocation kills even requests already in flight.
Your customer never receives a text message, never waits for an email, and never types a password. They see a code on your page and confirm it on the phone already in their hand.
07 — BILLINGPilot terms
Per-ceremony usage is metered per business per month. The pilot is count-only — no charge. Pricing tiers follow once live duration metrics settle, on the same prepaid credit system as the rest of the Blasts platform. Your partner record already carries the credit balance.
08 — VERIFYThe signed assertion is the login
When /status answers approved, the response also carries an assertion — a compact signed token (JWS, ES256) that cryptographically states who approved, for which business, and when. The assertion is what you authenticate on. The word “approved” by itself is a status report, not a credential.
{ "ok": true, "status": "approved",
"assertion": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImJsYXN0cy1sb2dpbitqd3QiLCJraWQiOiJ…",
"assertion_expires_at": 1787167500 } // seconds — exactly 5 minutes after the phone signed
What's inside
| Claim | Meaning |
|---|---|
iss | https://api.blasts.app — always. |
aud | Your partner_id. An assertion minted for another business never verifies for you. |
sub | The user's stable id for your business: same customer, same sub, forever. Different businesses see different subs for the same person, so accounts can't be correlated across partners. Key your user table on it. |
exp / iat | Five-minute life, fixed. Verify at sign-in time; never store an assertion for later use. |
jti / request_id | One identity per ceremony — re-polls of the same login carry the same jti. Use it as your idempotency key: one assertion, one session. |
amr / auth_time | ["blasts-device-approval"], and the moment the phone signed the verdict. |
Two ways to verify
Locally. Fetch our published keys from https://api.blasts.app/.well-known/blastslogin-jwks.json, pick the entry matching the token's kid, and verify with any standard JOSE library. Cache the key set for up to one hour. If a token arrives with a kid you don't have, refetch the JWKS once — if it's still unknown, fail the login. Never pin a single key; we rotate on a schedule, and the current and next signing keys are normally both published — an unknown kid almost always means “refetch,” not “forged.”
Rotation honesty: verification failures are possible for up to your JWKS cache TTL (max 1 h if you honor our cache-control) after an emergency rotation. During that window, /verify below is the instantly-consistent path.
Or let us check it. POST /api/v1/partner/login/verify with the same three auth headers and body { "assertion": "…" }. Answer: { "valid": true, "claims": { … } } or { "valid": false, "reason": "malformed | bad_signature | expired | wrong_audience" }. This route checks the live key set server-side, so it stays consistent even mid-rotation while JWKS caches drain.
Zero-effort path — not on npm yet. Our server-side SDK @blasts/kit-node implements everything in this section — the signed calls, the poll, both verification paths, the refetch-once ladder, and rule 6 — and returns an authenticated session only on a verified assertion. It is not published yet, so npm install will not find it: until it ships, the copy-paste verification below and the sample app pattern against the raw HTTP surface are the supported path (same rule as our SDK versioning policy). Want the SDK source in the meantime? Email AL@SavingsSites.com and we will send it.
Copy-paste verification
Node, with the jose package. createRemoteJWKSet refetches automatically when a token carries an unknown kid — that is exactly the required ladder — and cooldownDuration bounds how often it can refetch:
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://api.blasts.app/.well-known/blastslogin-jwks.json'),
{ cacheMaxAge: 3600000, cooldownDuration: 30000 } // 1 h cache, 30 s refetch floor
);
async function verifyAssertion(assertion, myPartnerId) {
const { payload } = await jwtVerify(assertion, JWKS, {
algorithms: ['ES256'],
typ: 'blasts-login+jwt',
issuer: 'https://api.blasts.app',
audience: myPartnerId,
maxTokenAge: '5m'
});
return payload; // throws on ANY failure — bad signature, expired, wrong aud, unknown kid
}
Python, with PyJWT. PyJWKClient caches the key set and refreshes it once when a kid is not found — the same ladder:
import jwt
from jwt import PyJWKClient
jwks = PyJWKClient(
"https://api.blasts.app/.well-known/blastslogin-jwks.json",
cache_keys=True, lifespan=3600, # 1 h cache
)
def verify_assertion(assertion: str, my_partner_id: str) -> dict:
if jwt.get_unverified_header(assertion).get("typ") != "blasts-login+jwt":
raise jwt.InvalidTokenError("wrong typ")
key = jwks.get_signing_key_from_jwt(assertion)
return jwt.decode(
assertion, key.key, algorithms=["ES256"],
issuer="https://api.blasts.app", audience=my_partner_id,
) # raises on ANY failure — bad signature, expired, wrong audience
The six rules
- Verify the signature against a JWKS key — or post it to
/verify. - Accept only
alg: ES256andtyp: blasts-login+jwt. Reject anything else, especiallyalg: none. - Check
issis exactlyhttps://api.blasts.appandaudis yourpartner_id. - Check
exp. Expired means the user signs in again — not “probably fine.” - Record
jtiand refuse a repeat. One assertion, one session. - An approved status without a verified assertion is not authenticated. If the poll says
approvedbut carriesassertion_unavailable: trueinstead of an assertion, that is a monitoring signal from our side — never a login. Do not fall back to status-only sign-in, and do not admit the user.
The poll tells you what happened; the assertion proves it. Only our signing key can produce that proof, and your verification is what turns the proof into a session.
Want to see all of this running? The sample app is this entire guide as one dependency-free Node file — connect, ring, poll, verify, session. Our upgrade and deprecation commitments live in the support & versioning policy.