How it works
- Register your app below: its name, its homepage and the exact addresses members are sent back to. You get a client ID at once, and a client secret too if your app runs on a server.
- Send people to Sign in with Send. Your app opens
/oauth/authorizewith the scopes it wants, astateand a PKCE challenge. The member signs in here the way they always do, two-factor included, and sees your app’s name, its website and the address they will be sent back to, with one tick-box for each thing you asked for. Every box starts unticked. The first time a member lets your app in, they also confirm it’s them (their password, two-factor step or wallet), as for any security change here. - They come back with a one-time code. Your app swaps it at
/oauth/tokenfor an ID token (a signed JWT) and an access token. Nothing reaches your app before they press Allow, and only what they ticked. - Keep it current. Send Power, level and Boost move.
/oauth/userinfogives you today’s values, and when the member ticks it, a refresh token keeps them up to date for 30 days without a new sign-in.
It is standard OAuth 2.0 (the authorization code flow with PKCE) with OpenID Connect on top, so an OpenID Connect library signs people in from the discovery address under Endpoints, as long as it accepts ID tokens signed with ES256, the only algorithm used here. It has been tried with openid-client 6 and 5, oidc-client-ts 3 and Auth.js (@auth/core). A library that expects RS256 unless told otherwise needs telling: in openid-client 5 and next-auth 4, set id_token_signed_response_alg: 'ES256' on the client (next-auth: client: { id_token_signed_response_alg: 'ES256' }); in Spring Security, give the decoder a JWS algorithm resolver that answers ES256.
The privacy promise
- Your app gets only what the member ticks. Every box starts unticked, and an unticked box is not shared: not in the ID token, not from userinfo.
- A private ID for each app. The
subyour app receives is different in every app, so two apps cannot match a member up by it. It is made at random the first time the member lets your app in and kept, so it stays the same for your app, revokes included, until the account or your app is deleted. The Send ID, which is the same everywhere, is shared only when the member ticks it, and its box says so.auth_timeis sent only when you ask how recent the sign-in is (max_ageorprompt=login), and then it is worked out from your request, never read off the member’s session: the earliest time yourmax_ageallows (never later than the real sign-in), or the exact second of a fresh proof the member made for your request. So it cannot match a member up either. One honest limit:max_ageis weighed against the member’s last sign-in or proof floored to ten minutes, so a reviewed app that sends silentprompt=nonerequests with differentmax_agevalues can narrow that time down to ten minutes. - Never shared with any app: email, wallets, IP address and sign-in history. No scope asks for them. Holdings, OG tier and communities are not sent as such either, but the Send Boost is made of them: it grows with how much $SEND and $GWC a member’s wallets hold (and for how long), with their OG tier, with being in a live community and with Rocket Run, weekly and beta boosts. So an app given the Boost can estimate a member’s holdings from it and see when any of these change, and the member’s box says so.
- Some of what members tick is public anyway. A member’s exact Send Power and profile picture are shown next to their handle on their public Send Wall and on the leaderboards, so an app given either can find the handle and recognise them across apps, as the Send ID does. Send IDs go in the order members joined, and when each member joined is public on their Wall, so the Send ID can narrow down the handle too (or give it at once, for a member who made their Send ticket public). Their boxes say so.
- What this site keeps: your app’s registration; for each member who allowed it, what they ticked, when they first allowed it and when they last changed that, until they revoke it; the private ID your app knows them by; while your app is signed in, its tokens, each stored as a hash and deleted when it expires (a used refresh token is not kept: each one names its sign-in, so an old one presented again is still caught); and the signing keys, their private halves encrypted.
- What it does not keep: any record of when or how often anyone uses your app; your userinfo calls; IP addresses or devices, beyond the rate-limit counters the whole site keeps in memory for up to about an hour.
- Nothing from anywhere else on the sign-in pages. The pages under
/oauth/load no analytics, no beacon and nothing from another site, cannot be put in a frame, and are never cached. - Members stay in charge. Settings, under Apps you’ve signed in to, lists every app, what it can see and since when. Revoke ends its tokens at once. Changing the password, ending other sessions or turning two-factor off signs the member out of every app too, and the next sign-in asks again. Deleting the account revokes everything, and an account put in read-only mode is signed out of every app until it lifts.
- Checkable without asking us again. The ID token is signed (ES256) with a key published at
/.well-known/jwks.json, so your server checks it on its own: a Send passport, in effect. A new sign-in needs this site to be up; a token you have already checked stays checked.
What an app can ask for
Ask for the scopes in scope, separated by spaces. A scope this site does not know (email, profile and the like) is left out rather than refused, and the token answer’s scope says what was granted. Ask for what you need: a short list is an easy one to tick.
| Scope | Claims it adds | What the member is shown | Example |
|---|---|---|---|
openid always | sub | A private ID for them in your app alone: different in every app | "q2C1vYw0LrX8nS4eKpQ7tZ3mB9dF6hJ1aW5cE0gU2yI" |
send.handle | preferred_username, profile | Their @handle and the link to their public Send Wall | "alice", "https://sendrh.com/u/alice" |
send.picture | picture | The address of their public profile picture (left out when they have none), with a note that it is next to their handle on their public Send Wall, so it finds the handle | "https://sendrh.com/uploads/a1b2c3.webp" |
send.id | send_id | Their Send ID: the same number in every app they share it with, so apps can recognise them across each other. Send IDs go in the order members joined, and join dates are public on Send Walls, so it can narrow down or find the handle | 12 |
send.power | send_power, send_level, send_title | Their Send Power points, the level they make and its title, with a note that the exact points are public next to their handle on their Send Wall, so they find the handle | 15200, 31, "Send Sergeant" |
send.boost | send_boost | The total multiplier every point of theirs is paid at, as one number, with a note that it is made of their holdings, OG tier, communities and boosts, so an app can estimate their holdings from it | 3.5 |
send.verified | send_verified | Whether a wallet of theirs passed the holder check: not which wallet, not how much it holds | true |
offline_access | none: a refresh token | Keeping the values above up to date for 30 days without a new sign-in | "refresh_token" in the token answer |
The same claims, with the same values, are in the ID token and in userinfo. The ID token holds them as they were when it was issued; userinfo holds them as they are now. The picture is served so that your own pages can show it: an <img> on your site loads it.
Endpoints
The issuer is exactly https://sendrh.com. A library needs only the discovery address; the rest are listed in it.
| What | Address |
|---|---|
| Discovery | GET https://sendrh.com/.well-known/openid-configuration |
| Signing keys (JWKS) | GET https://sendrh.com/.well-known/jwks.json |
| Sign-in page | GET https://sendrh.com/oauth/authorize |
| Token | POST https://sendrh.com/oauth/token |
| Userinfo | GET or POST https://sendrh.com/oauth/userinfo |
| Revoke | POST https://sendrh.com/oauth/revoke |
| Introspection (your own tokens) | POST https://sendrh.com/oauth/introspect |
| Handles check (reviewed server apps) | POST https://sendrh.com/oauth/handles |
The rules
response_type=codeonly. PKCE withcode_challenge_method=S256is required of every app;plainis refused.stateis required (up to 2,048 characters) and comes back with the answer, together withiss. Anonceyou send is put in the ID token.redirect_uriis matched exactly, character for character: case, a trailing slash and the query all count. Register it in plain ASCII, an international domain in itsxn--form, exactly as your app sends it. A wrong address or an unknown app is shown an error page and sent nowhere.- A code works once, within 60 seconds, for the app, address and PKCE verifier it was made for. Using it a second time revokes every token issued from it.
- An access token lasts an hour and an ID token ten minutes: stop using a token once its
expires_inhas passed, or when it is answered 401. A refresh token changes at every use: the one you sent stops working at once, and of that sign-in’s access tokens only the one just replaced keeps the rest of its hour. A used refresh token presented again revokes the whole chain, and the chain ends 30 days after the sign-in. One member keeps at most 20 live sign-ins in one app: a 21st ends one of the others, first one without a refresh token, then the one used longest ago (as far as its tokens show: an access token says when its sign-in was last exchanged or refreshed, for its hour). - Send
/oauth/tokenand/oauth/revokeasapplication/x-www-form-urlencoded. A server app authenticates withclient_secret_basicorclient_secret_post; a browser or mobile app sends itsclient_idalone. - Token, userinfo, revoke, discovery and the keys answer any origin (CORS, never with cookies), so a browser app can call them directly.
prompt=noneanswers with a code, orlogin_requiredwithout showing anything: a member who is signed out, whose account cannot sign in to apps, or who has not allowed your app (for what you asked) is answered the same way, in the same words, so your app learns nothing before the member presses Allow; send them again withoutprompt=none.prompt=consentasks the member again, every box unticked.prompt=login, and amax_agethe member’s sign-in is older than, ask for a fresh proof (or answerlogin_requiredwithprompt=none); a member who has already answered for everything you asked is asked for the proof alone, with no boxes, and what they allowed stays as it was.prompt=select_accountshows them which account they are signed in with, the same way.max_ageis weighed against the last sign-in or proof floored to ten minutes, with a minute to spare, so one under about eleven minutes may ask for a fresh proof even right after one (it depends on where that proof fell in its ten minutes), and under a minute it always does; to force one, useprompt=login.- Until an admin has reviewed your app, an error is not sent to your address on its own: the member is shown it, with a link back to your address that carries the same
errorandstate. That keeps this site from being a redirector anyone could aim. Once reviewed, errors (andprompt=none’s answers) come back at once. - Use a full-page redirect: once a member has allowed your app it comes straight back without showing a page. A pop-up works with a library that does not need
window.opener(the pages under/oauth/cut it). A hidden iframe withprompt=nonenever sees a member’s sign-in here (the site’s cookie is not sent to other sites’ frames). Once your app is reviewed it always answerslogin_required; until then it gets no answer at all, because the error is a page shown to the member and pages here cannot be framed, so a library waits for its own timeout. To stay up to date in the background, ask foroffline_accessand use the refresh token (oidc-client-ts:automaticSilentRenewonly with a refresh token). - Your server trusts only what it checked itself: an ID token whose signature,
issandaud(your client ID) it verified, or an access token whose userinfo answer (or introspection) names your client ID inaudandclient_id. Never trust claims or a token a browser hands over without that check: another app’s token for the same member would pass. - Errors are the standard OAuth ones, as JSON
{ error, error_description }:invalid_request(a missinggrant_typeincluded),invalid_client(401, always with aBasicchallenge),invalid_grant,invalid_scope,unsupported_grant_type,unauthorized_client(403: the handles check, for an app that may not use it) andslow_down(429). At your redirect address:access_denied(the member pressed Cancel),login_required,invalid_request,invalid_scope,unsupported_response_type,request_not_supportedandrequest_uri_not_supported. Write your code against theerrorvalue; the description is for people. - No one member can use up an app’s minute, and nor can anyone else. A browser app has no shared minute at all: each member has their own share in it, across all their tokens (30 token requests, 120 userinfo requests and 30 introspections a minute), one sign-in chain refreshes at most 10 times a minute and 120 an hour, and one token reads userinfo 120 times a minute. A server app that proves its secret has budgets of its own and is never counted against its address, so a server calling from one address is not held back by it: 600 exchanges and refreshes that go through, 3,000 userinfo requests with its live tokens and 600 introspections of them a minute, and 120 handle checks. What such an app sends that is refused (a made-up code relayed to its callback, a random token sent to its API) counts in a separate budget (3,000 token requests and 6,000 introspections a minute) that never holds back one that goes through. Any other request that carries nothing real counts against its address: 120 token requests or browser-app introspections a minute, and 4,000 userinfo requests with a token this site does not know (a token past its hour counts against its member’s share instead; a live one never counts against its address). Each address has a backstop of 3,000 token requests and introspections that did not prove a secret, 6,000 userinfo requests without a live token, and 120 sign-in pages in ten minutes. Five apps per member, five redirect addresses per app.
- The handles check, for reviewed server apps that keep members’ handles theirs in your app too:
POST /oauth/handleswith your client authentication and{ "handles": ["alice", "@bob"] }(up to 50) answers{ handles: { alice: true, bob: false }, taken: ["alice"] }: yes or no for each, whatever the capitals, keyed by each handle in lower case without its@, and nothing more about anyone.
Example: a browser app, no library
A public app (no secret, because a browser cannot keep one; PKCE is its proof). Register it as public with the address of your callback page, put your client ID in, and call signInWithSend() from your button.
// Sign in with Send from a browser app: no library and no secret (PKCE is the proof)
const ISSUER = 'https://sendrh.com';
const CLIENT_ID = 'sapp_your_client_id'; // from Your apps, below
const REDIRECT_URI = location.origin + '/callback'; // registered exactly as written
const SCOPE = 'openid send.handle send.id send.power send.boost';
const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const random = () => b64url(crypto.getRandomValues(new Uint8Array(32)));
async function signInWithSend() { // call it from your button
const verifier = random(), state = random();
const challenge = b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)));
sessionStorage.setItem('send-signin', JSON.stringify({ verifier, state }));
location.assign(ISSUER + '/oauth/authorize?' + new URLSearchParams({ response_type: 'code', client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI, scope: SCOPE, state, code_challenge: challenge, code_challenge_method: 'S256' }));
}
async function finishSignIn() { // run it on the page at REDIRECT_URI
const q = new URLSearchParams(location.search), saved = JSON.parse(sessionStorage.getItem('send-signin') || '{}');
sessionStorage.removeItem('send-signin');
if (!saved.state || q.get('state') !== saved.state || q.get('iss') !== ISSUER) throw new Error('not the sign-in we started');
if (q.get('error')) throw new Error(q.get('error')); // access_denied: they pressed Cancel
const res = await fetch(ISSUER + '/oauth/token', { method: 'POST', body: new URLSearchParams({ grant_type: 'authorization_code',
code: q.get('code'), redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: saved.verifier }) });
const tokens = await res.json();
if (!res.ok) throw new Error(tokens.error);
const me = await fetch(ISSUER + '/oauth/userinfo', { headers: { Authorization: 'Bearer ' + tokens.access_token } });
return me.json(); // { sub, aud, client_id, preferred_username, profile, send_id, send_power, ... }: what they ticked
}
The answer holds only the claims the member ticked: a box they left unticked is simply missing. Keep the access token in memory and ask userinfo again when you want fresh values; it lasts an hour.
A server, with an OpenID Connect library
A confidential app: register it as confidential and keep the secret on your server. Shown with openid-client 6 for Node; another OpenID Connect client works the same way from the discovery address once it accepts ES256 ID tokens (see How it works, above).
import * as client from 'openid-client';
const config = await client.discovery(new URL('https://sendrh.com'), 'sapp_your_client_id', 'ssec_your_client_secret', undefined,
{ execute: [client.enableNonRepudiationChecks] }); // so the ID token's signature is checked too, against the published keys
// 1. your "Sign in with Send" route
const verifier = client.randomPKCECodeVerifier(), state = client.randomState();
req.session.sendSignIn = { verifier, state }; // wherever your app keeps a sign-in in progress
res.redirect(client.buildAuthorizationUrl(config, { redirect_uri: 'https://your.app/callback', scope: 'openid send.handle send.id send.power',
code_challenge: await client.calculatePKCECodeChallenge(verifier), code_challenge_method: 'S256', state }).href);
// 2. your callback route, at https://your.app/callback
const { verifier: v, state: s } = req.session.sendSignIn;
const tokens = await client.authorizationCodeGrant(config, new URL(req.url, 'https://your.app'), { pkceCodeVerifier: v, expectedState: s });
const claims = tokens.claims(); // the ID token, checked: signature (ES256, the published keys), issuer, audience, expiry
const fresh = await client.fetchUserInfo(config, tokens.access_token, claims.sub); // today's Send Power, level and Boost
Checking an ID token yourself
No library needed: Node’s own crypto checks the signature against the published keys. This is all it takes to trust what an ID token says without asking this site.
import { createPublicKey, verify } from 'node:crypto';
const ISSUER = 'https://sendrh.com', CLIENT_ID = 'sapp_your_client_id';
let jwks = null; // keep it, and fetch it again when a token names a key you do not have
async function checkIdToken(idToken, nonce) {
const [h, p, s] = idToken.split('.');
const head = JSON.parse(Buffer.from(h, 'base64url')), claims = JSON.parse(Buffer.from(p, 'base64url'));
if (head.alg !== 'ES256') throw new Error('unexpected algorithm'); // the only one: never accept "none"
if (!jwks || !jwks.keys.some((k) => k.kid === head.kid)) jwks = await (await fetch(ISSUER + '/.well-known/jwks.json')).json();
const jwk = jwks.keys.find((k) => k.kid === head.kid);
if (!jwk) throw new Error('unknown signing key');
const key = createPublicKey({ key: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }, format: 'jwk' });
if (!verify('sha256', Buffer.from(h + '.' + p), { key, dsaEncoding: 'ieee-p1363' }, Buffer.from(s, 'base64url'))) throw new Error('bad signature');
const now = Math.floor(Date.now() / 1000); // a minute of leeway for a clock that is a little out
if (claims.iss !== ISSUER || claims.aud !== CLIENT_ID || claims.exp + 60 < now || claims.iat - 60 > now) throw new Error('not for this app, or expired');
if (nonce !== undefined && claims.nonce !== nonce) throw new Error('wrong nonce');
return claims; // sub, and the claims the member ticked: preferred_username, send_id, send_power and the rest
}
The Sign in with Send button
Two colours, as SVG files or as HTML you paste in. The HTML version takes your page’s font size and is never less than 44 pixels tall. Point it at your own route that starts the sign-in, and keep the words “Sign in with Send” so members know what they are pressing.
As HTML, dark
<a href="https://your.app/sign-in-with-send" style="display:inline-flex;align-items:center;gap:0.6em;min-height:44px;padding:0.4em 1.1em 0.4em 0.6em;box-sizing:border-box;border:2px solid #c6f000;border-radius:999px;background:#0c0e09;color:#f2f6ec;font-family:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-weight:700;line-height:1.2;text-decoration:none"><svg width="1.5em" height="1.5em" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="12" fill="#c6f000"/><path d="M16.2 8.2C15.4 6.9 13.9 6.3 12.2 6.3 9.9 6.3 8.2 7.5 8.2 9.3c0 1.9 1.8 2.5 3.8 2.9 2.2.4 4 1.1 4 3.1 0 1.8-1.8 2.9-4.1 2.9-1.9 0-3.5-.8-4.3-2.2" fill="none" stroke="#0c0e09" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>Sign in with Send</a>
As HTML, lime
<a href="https://your.app/sign-in-with-send" style="display:inline-flex;align-items:center;gap:0.6em;min-height:44px;padding:0.4em 1.1em 0.4em 0.6em;box-sizing:border-box;border:2px solid #020301;border-radius:999px;background:#c6f000;color:#020301;font-family:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-weight:700;line-height:1.2;text-decoration:none"><svg width="1.5em" height="1.5em" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="12" fill="#020301"/><path d="M16.2 8.2C15.4 6.9 13.9 6.3 12.2 6.3 9.9 6.3 8.2 7.5 8.2 9.3c0 1.9 1.8 2.5 3.8 2.9 2.2.4 4 1.1 4 3.1 0 1.8-1.8 2.9-4.1 2.9-1.9 0-3.5-.8-4.3-2.2" fill="none" stroke="#c6f000" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>Sign in with Send</a>
Your apps
Any member can register up to five apps, free. An app works as soon as it is registered. Its sign-in page says “Not reviewed by Just Send It” until an admin has looked at it, and changing its name, homepage, description or addresses clears a review. Names, homepages and descriptions that pass for this site are refused: its names in any spelling, its address, its button’s and account’s words, staff words, and letters from other alphabets in a name. An admin can switch off an app that misleads people: its sign-ins and tokens stop at once, it stays exactly as it was (no edit, no new secret, no delete) until the admin decides, and the hosts it sent members to cannot be used by any other app, or by a name under them, until the admin lifts that — even if the app or its developer’s account is deleted. An app that already had one of those hosts keeps it. Changing an app’s redirect addresses or homepage, making it a new secret and deleting it each ask you to confirm it’s you, as any security change here does. Adding a redirect address on a new origin — a scheme, host or port the app did not have — signs every member out of the app, and each is asked again at their next sign-in, because what they allowed covered where the app sent them then. Another address on this device (http://localhost or 127.0.0.1, any port) does not, once the app has one.
Checking whether you are signed in.