What's new in Rekey 2.2 since rc.1
Blog ReleaseThe first 2.2.0 post covered the first release candidate, rc.1. Three more have shipped since, and 2.2.0-rc.4 is on npm today. This post is what those three let you do, with the biggest changes for your customers first: emails that look like they came from your product, control over what a new user sees and receives, billing and two-factor that are harder to get wrong, and a checkout page with your name on it.
Emails that look like your product
Password resets, verification links, magic links, the welcome email and the failed payment reminder were redesigned. They now carry your display name, logo and primary colour from your portal branding, with subjects like “Reset your Acme password”. A new Sender section sets the From name, a Reply-To so answers reach you, and a support address for the “Need help?” line. Dates read as 27 Sep 2026, 14:50 UTC, and the payment reminder gets an Update payment method button when your hosted portal is on. The “Sent via Rekey” line is gone. Rekey Cloud Free workspaces show a small “Secured by Rekey” line instead; paid workspaces and self-hosted installs show nothing.
Your own product emails can now live in Rekey too. Write an “order shipped” or “invoice ready” template in the panel, preview it, send yourself a test, publish it, and send it by key from your backend:
await rekey.email.send({
template: 'order_shipped',
to: '[email protected]',
variables: { orderNumber: 'A-1042' },
idempotencyKey: 'order-shipped:A-1042',
});The call carries only the template key and the values, so the words and the sender are always what you approved. Custom templates need your own Resend or SMTP provider and a key with the email:send scope. Notification templates carry one-click unsubscribe, which never stops password resets or sign-in links. The full walkthrough is in sending your own emails through Rekey.
Control over what a new user sees
Every sign-in and sign-up result now says whether that request created the account, so routing a first-timer to a setup screen is one line:
const session = await signUp({ email, password }); // @rekey.dev/nextjs/server
redirect(session.isNewUser ? '/onboarding' : '/dashboard');- New webhooks tell the rest of your stack:
session.createdon every real sign-in, withfirstSignIntrue exactly once per user;organization.invitation.createdand.accepted;subscription.trial_started; andsubscription.trial_will_end, three days before a trial ends, which is the moment for a reminder. user.updatednow actually fires, and users your team creates in the panel emituser.createdwithvia: "operator".- The welcome email has a timing setting: at sign-up (the default), once the address is verified, or off when your own sequence does the welcoming. OAuth sign-ups now get it once, and with email verification required it waits for the first verification.
- Sign-up email rules let you allow or block domains and refuse throwaway inboxes. Existing users keep signing in.
Code for all of it, including a webhook handler that checks the signature, is in sending new users to onboarding.
Billing that is harder to get wrong
Most of these close a gap where a customer could be charged, credited or granted the wrong thing without anyone noticing.
- Refunds add up. Two partial refunds on one payment now read as
PARTIALLY_REFUNDEDand thenREFUNDED, a refund larger than the payment is refused, and revenue stats count what you kept. Stripe'scharge.refundedis now handled. - A second checkout for the same plan while the first is being paid answers
409 CHECKOUT_PAYMENT_IN_PROGRESS, so a double-click does not produce two subscriptions. - Checkouts are rate limited per buyer, per address and per Application, and a return URL that is not http(s) is refused. A return URL on an origin you have not registered still works for now, with a warning. A later release will refuse it, so register your origins now.
- The free default plan must actually be free. Pointing it at a priced plan answers
409 BILLING_FREE_PLAN_NOT_FREE. - Granting someone a plan they already hold, on a different organization or on their personal account, is refused with
409 BILLING_SUBSCRIPTION_SUBJECT_CONFLICT. On the external provider, an activation dated before a cancellation no longer brings the subscription back. - Stripe Checkout used to force cards. It now offers Link, wallets and whatever else your Stripe account enables, and a delayed method such as a bank debit activates when the money arrives.
- A buyer who holds several subscriptions can list and cancel each one, and the portal never offers a plan they already have.
Two-factor codes work once, and sessions stay put
A two-factor code is now accepted once per factor. A replayed code, a reused sign-in challenge, or one backup code spent from several requests at once are all refused. The reused code answers MFA_CODE_REUSED and does not count toward the MFA lockout. Your users will notice one thing: the code that confirms enrolment cannot also finish the first sign-in, so they wait for the next one.
Since rc.3, a signed-in user with two tabs open no longer gets signed out of every device when both tabs refresh at once. That race used to look like a stolen token, so the API revoked all their sessions. It now answers REFRESH_TOKEN_RACED, revokes nothing, and @rekey.dev/nextjs and @rekey.dev/astro recover by themselves. In Next.js the refresh route is one line:
// app/api/rekey/refresh/route.ts
import { rekeyRefreshHandler } from '@rekey.dev/nextjs/server';
export const GET = rekeyRefreshHandler();A checkout page with your name on it (beta)
A checkout can now land on a Rekey-hosted page with your name, logo and colours, the order summary, the renewal terms, and your Terms, Privacy and Refund links, with the payment provider's own buttons inside it. Card details stay in the provider's window, and the subscription still activates only from the provider's verified webhook.
Opt-in, and rolling out
To see exactly what your buyers will see, switch only the Test column to the Rekey page and buy with sandbox credentials. A banner on that page says no real money moves, and your live checkouts stay on PayPal's page meanwhile. Your code does not change: POST /billing/checkout still returns a url to redirect to, with mode: "embedded" when it is the Rekey page. Pass mode: "redirect" to send one checkout to the provider's page.
Smaller things
npx @rekey.dev/cliandnpx @rekey.dev/mcpused to exit without doing anything. They run now.rekey --helpprinted the super-admin key when it was set in your environment. It no longer does. If help output may have ended up in a log, rotateSUPER_ADMIN_KEY.rekey.auth.listOAuthProviders()tells a sign-in page which social buttons to draw, and the React<SignIn>and<SignUp>can fetch them for you.- Portal sign-in errors say what happened. A locked-out customer is told to wait, with the minutes when they are known, and a service failure says their account is fine.
Before you upgrade
A few changes are visible to an existing integration. Read the 2.2.0-rc.4 changelog first, especially these:
- Code that retries MFA verification after a success must sign in again, because the challenge answers
MFA_CHALLENGE_USED. - Stripe events whose live or test mode contradicts the credential are answered
409 WEBHOOK_MODE_MISMATCH. Check your Stripe credential rows, then re-run Auto-configure on the webhook so it receives the new events. - End-user subscription responses are an allowlist now, so operator notes in
metadatano longer reach a browser. - Self-hosted: six migrations run on boot, all additive, and every new environment variable is optional.
