Link SDK
The front-end your users see. One script, one modal, a verified bank and round-ups turned on.
SideBet Link is shaped like Plaid Link or Stripe’s bank-link flow. Your server mints a short-lived link token for one user with your merchant API key. Your page loads one script and opens a modal that explains round-ups, lets the user pick how much to save, links their bank on whichever payment rail your account uses, records the debit authorization, and hands back a result. Your merchant key never reaches the browser.
your server ──(merchant API key)──▶ POST /api/roundup/link-token ──▶ { linkToken, expiresAt }
│
└── page: <script src=".../sdk/sidebet.js"> SideBet.create({ linkToken, onSuccess }).open()
│
└──(link token only)──▶ /api/link/session · /bank-link · /bank-link/complete · /statusIntegration in three steps
Mint a link token on your server
One per page load or user session. Tokens are signed, scoped to that user, and expire in 30 minutes.
Open Link in the browser
Load the script, call SideBet.create({ linkToken }).open(). The modal handles consent, choices, bank linking and errors.
Record purchases server-side
POST /api/roundup/initiate on every bet. Nothing is debited per purchase; accrued cents are collected in one transfer at the threshold.
1. Server: mint a link token
curl -X POST https://api-production-610a.up.railway.app/api/roundup/link-token \
-H "Authorization: Bearer $MERCHANT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"consumerId": "user_123",
"consumerEmail": "ada@example.com",
"consumer": { "firstName": "Ada", "lastName": "Lovelace", "phone": "+14155550123" },
"ttlSeconds": 1800
}'
# → { "linkToken": "slt_…", "expiresAt": "2026-09-02T20:30:00Z" }| Parameter | Type | Description |
|---|---|---|
consumerId | string | Your stable id for this user. Everything SideBet stores is keyed on it. |
consumerEmail | string | Used for receipts and, on the Stripe rail, the bank-link session. |
consumer.firstName / lastName | string | Pre-fills the details step. Required by Aeropay before a bank can be linked; if you omit them the modal asks. |
consumer.phone | E.164 string | Aeropay identifies users by mobile number. Pass it to skip the details step entirely. |
ttlSeconds | number | Token lifetime. Default 1800. |
2. Page: open Link
<script src="https://api-production-610a.up.railway.app/sdk/sidebet.js"></script>
<script>
const link = SideBet.create({
linkToken, // from your server
onSuccess(result) { // bank verified, round-ups on
console.log(result.bank, result.accrual, result.rules, result.wallet);
},
onExit(reason, err) {}, // 'closed' | 'error' | 'expired'
onEvent(e) {}, // open, view, settings_saved, bank_link_started,
// bank_linked, success, exit, error
theme: { accent: '#0A6E5C' }, // optional override of the brand preset
});
link.open();
</script>ESM builds are served at /sdk/sidebet.esm.js. TypeScript definitions ship alongside.
3. Server: record purchases
curl -X POST https://api-production-610a.up.railway.app/api/roundup/initiate \
-H "Authorization: Bearer $MERCHANT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "consumerId": "user_123", "consumerEmail": "ada@example.com", "transactionCents": 437 }'
# → { "roundUpCents": 37, "accrual": { "accruedCents": 37, "thresholdCents": 500 }, "debit": null }What the user sees
Copy, logo, accent color and every money choice come from your Link preset, configured in the SideBet admin and published as a version. The modal walks through these steps, skipping any that do not apply.
| Step | What happens |
|---|---|
| Consent | Logo lockup, headline, three bullets, the debit authorization in plain words, Agree and continue. Every first-time link passes through here, whatever entry point opened the modal. |
| Choose | Only the controls your preset allows: transfer threshold chips, save-the-cents vs next-dollar, 1×–3× boost, destination. A live example line and a "≈ N bets to your next transfer" estimate update as they tap. |
| Details | Aeropay rail only. Legal name and mobile number, pre-filled from the link token when you passed them. |
| Code | Aeropay rail only, and only when that phone already has an Aeropay account. Six-digit SMS code. |
| Bank | Stripe Financial Connections or the Aerosync widget, depending on your rail. The user picks their bank and signs in; SideBet never sees credentials. |
| Success | Bank on file, Active or Paused pill, progress bar, current rule and last transfer. "Manage round-ups" reopens Choose. |
Built to a Stripe Link quality bar
Every step is a real form: Enter submits, the first field is focused, Tab stays inside the dialog, Esc closes and returns focus. Inputs are labelled, errors are announced to screen readers, and every tap target is at least 44px. Under 480px the modal becomes a bottom sheet. Provider errors are rewritten for people, so a wrong SMS code says “That code didn’t match” rather than a vendor error code.
Progress widget
A small embeddable that shows “accrued / threshold” with a bar, refreshed on a timer. It renders in a shadow root so your CSS does not leak in and picks light or dark text from the container it sits in.
const widget = SideBet.widget({
linkToken,
el: '#roundups',
refreshMs: 15000,
onManage: () => SideBet.settings({ linkToken }).open(),
});
// The figure counts up as bets land. When a transfer settles the bar fills green,
// the widget shows "$3.85 moved to your wallet", then settles to "Last transfer $3.85 · today".
// Confetti fires once, on the user's first transfer.
widget.refresh(); // call after your own events if you want it instant
widget.destroy();SideBet.status({ linkToken }) returns the same data as JSON if you would rather draw your own.
Manage round-ups
SideBet.settings({ linkToken, onSuccess(r) { console.log(r.rules) } }).open();
// Unlinked users are routed to consent first — settings() can never skip the authorization.
// Server-side equivalent: PUT /api/roundup/consumer/:id/settings
// { thresholdCents, strategy, multiplier, destination, paused }
// You receive roundup.settings_changed on your webhook URL when a choice changes.States the modal handles
| Situation | What the user sees |
|---|---|
| New consumer | Consent → Choose → bank picker → "Round-ups are on" |
| Bank already verified | Straight to the success screen with current progress |
| Aeropay: name or phone unknown | "A little about you" form, then the link continues |
| Aeropay: phone already has an account | SMS code screen; on a miss the field clears and refocuses |
| Microdeposit verification pending (Stripe) | "Almost there" — round-ups keep tracking, the transfer waits |
| User cancels the bank widget | Back to consent |
| Round-ups paused | "Round-ups paused" with an orange pill and a Resume action |
| Token expired | "Session expired" → onExit('expired') — mint a new token |
Security model
- Link tokens are HMAC-signed, 30-minute, scoped to one merchant and one consumer. Nothing is stored server-side.
/api/link/*is CORS-open on purpose: it runs on your origin, the token is the credential, and it can only act on its own consumer. Rate-limited per IP.- The browser only ever receives publishable keys. Bank credentials go to Stripe or Aerosync directly.
- All dynamic strings are HTML-escaped and the UI renders in a shadow root.
Events
| Parameter | Type | Description |
|---|---|---|
open | event | Modal mounted. |
view | { step } | A step rendered: consent, choose, details, mfa, bank, pending, success, error. |
settings_saved | { settings } | The user changed threshold, rule, boost, destination or pause. |
bank_link_started | event | Handed off to Stripe or Aerosync. |
bank_linked | { status } | A bank came back. VERIFIED or PENDING_VERIFICATION. |
success | event | User pressed Done on the success screen. onSuccess fires with the result. |
exit | { reason } | closed, error or expired. |
error | { message } | Something failed; the modal shows a retry. |
Hedge, Inc. · Payments for the people who use them