What's new in Rekey 2.3
Blog ReleaseRekey 2.3 is mostly about the emails your app sends after sign-up and the moment a customer leaves. A reader can now unsubscribe from one kind of email and keep the rest, you can schedule a nudge and take it back, a canceled subscriber can tell you why, and a user can delete their own account. There is also a round of security and correctness fixes.
Two behaviour changes come first, because they can change what your users see the day you upgrade.
Before you upgrade
One-click unsubscribe now leaves one topic
Until now, the unsubscribe link in a custom notification email removed the reader from every notification email your Application sends. In 2.3, a one-click unsubscribe from a newly sent email removes them from that email's topic only. The confirm page has a second button for leaving every notification email. Links in emails sent before the upgrade keep the old meaning and still leave everything. If your support team tells people that one click stops all your mail, update that answer.
One-time purchases rank after recurring plans
GET /api/v1/billing/subscription returns one row when a buyer holds several. It now ranks a recurring paid plan first, then a one-time purchase such as a credit pack or a perpetual licence, then a free plan. Before, buying a credit pack could push the buyer's real plan out of that slot. If your app reads this endpoint to decide which plan someone is on, the answer is now the recurring plan.
An end user who tries to cancel a one-time purchase gets 409 SUBSCRIPTION_NOT_CANCELABLE, since it never bills again. The hosted portal no longer shows Cancel on those rows. If your own billing page offers Cancel on every row, hide it for credit packs and perpetual licences.
Three migrations
All three only add a table, columns or an index:
20261023202912_email_topic_opt_outs: a topic on custom templates and a table of per-topic opt-outs.20261023220638_subscription_cancel_reason: two nullable columns on subscriptions for the cancellation reason and feedback.20261024043609_scheduled_email_sends: the send time and request of a scheduled email on the email log, with an index.
The api container runs prisma migrate deploy on start, so they apply when the new API boots. Deploy the API, panel and portal together: the portal and panel read the new fields.
Unsubscribe from one topic, and a preferences API
A custom notification template can now carry a topic. It defaults to the template's key, and templates that share a topic are left together. A reader who tires of your onboarding tips can leave those and still get the weekly report. critical templates and Rekey's own account emails are never blocked by a topic opt-out.
For a settings page inside your app, read and change the same records from your backend:
const { allNotifications, topics } = await rekey.users.getEmailPreferences(userId);
await rekey.users.updateEmailPreferences(userId, { onboarding: "unsubscribed" });The update needs a key with the email:send scope, so a default key cannot sign someone back up. Setting a topic back to subscribed never lifts "leave every notification email" or a suppression you added. A topic no template was published with answers 400 EMAIL_TOPIC_UNKNOWN.
Every change, from the link or from your server, sends one email.unsubscribed webhook with data.address, data.endUserId when the address belongs to an end user, data.category and data.topic (absent when they left everything). See email preferences in the docs.
Send by user id, dry run, and read a send back
tocan be{ endUserId }instead of an address, so your worker never holds the address. A user who is unknown or erased answers404 EMAIL_RECIPIENT_NOT_FOUND, a banned one409 EMAIL_RECIPIENT_BANNED, and one who never confirmed their address409 EMAIL_RECIPIENT_UNVERIFIED. Nothing is sent on a refusal.rekey.email.dryRun(...)runs every check a real send runs, including the suppression list, and returns the rendered subject and body withwould_sendorsuppressed. It sends nothing, logs nothing and counts against no cap, so you can run it from CI to catch a template change that breaks your variables.rekey.email.getSend({ id })orgetSend({ idempotencyKey })returns the log row with its status. A worker that crashed mid-send can check what happened before trying again.
All three are in docs/email-templates.md.
Schedule a send, and cancel it
Pass sendAt, up to 30 days ahead, and Rekey holds the email until then. Every check runs when you schedule, so a broken template is refused when you schedule it. When it fires, a recipient who has since unsubscribed, been suppressed, banned or erased gets nothing. If the user does the thing you were going to nudge them about, cancel it:
const { id } = await rekey.email.send({
template: "finish_setup",
to: { endUserId },
variables: { name },
sendAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
idempotencyKey: `finish-setup:${endUserId}`,
});
// Later, when they finish setup:
await rekey.email.cancelSend(id);The walkthrough is in lifecycle emails without your own queue.
Know why subscribers cancel
When a customer cancels, you usually learn nothing. In 2.3 the cancel call takes an optional reason and feedback, and Rekey keeps both with the subscription.
await rekey.billing.cancelSubscription(userAccessToken, {
reason: "missing_features",
feedback: "No CSV export yet",
});| Reason | What the portal shows the subscriber |
|---|---|
too_expensive | It costs too much |
missing_features | It is missing something I need |
switched_service | I switched to something else |
unused | I am not using it |
too_complex | It is too hard to use |
low_quality | It did not work well enough |
customer_service | Support did not help |
other | Something else |
- Any other
reasonanswers400.feedbackis up to 1000 characters and only accepted with a reason; useotherwhen none fits. The list is exported asCANCEL_REASONSfrom@rekey.dev/shared-typesfor your own survey. - The hosted portal's cancel dialog asks "Why are you cancelling?", optional, with the labels above.
- In the panel, the end user's Subscriptions tab shows the reason and feedback.
- Every subscription webhook carries
data.subscription.cancellationwithreasonandfeedbackonce a reason is stored. A cancel at period end keeps it, so thesubscription.canceledthat arrives weeks later still says why. - If the subscription comes back (the provider resumes it, or a renewal is paid after the cancel), the reason is cleared, so your dashboards do not count a customer who stayed.
- Erasing the person deletes their feedback, on the row and in stored webhook deliveries. The reason stays, since it is one of eight fixed values.
Details in docs/billing.md.
Users can delete their own account
Users can now erase their own account with DELETE /api/v1/users/me, from the browser after confirming with their password or an MFA code, or from your backend. Each Application chooses self_service, server_only or off. Read letting users delete their own account.
Team invites that work under invite-only sign-up
If your deployment runs with OPERATOR_SIGNUP_MODE=invite, a teammate you invited could not create an account to accept it, and under open sign-up they got an empty workspace of their own first. Now the invitation link is enough: the invitee creates an account from it and lands in your workspace at the role you chose. The address must match the invitation. You can resend a pending or expired invitation, which gives it a fresh 7-day link and retires the old one.
The Team page is simpler too: members and open invitations in one list with Resend and Withdraw, and an invite dialog that explains each role in plain words and offers an admin only the roles they can grant. When you invite a member, you can also pick which apps they get and at what level, and that access applies the moment they accept. See docs/tenant-auth.md.
Keep device release with your support team
If your plan limits devices, a user at the limit could free a slot themselves from the browser. A new per-Application setting, deviceRelease, lets you set it to server_only: a browser release then answers 403 DEVICE_SELF_RELEASE_DISABLED and the user goes through support. Operators and your backend with a secret key can still release. DEVICE_LIMIT_REACHED now carries details.selfRelease, so your client knows whether to show a release button or a "contact support" line. It is set in the panel under Methods, Verification & sessions. See docs/devices.md.
A tidier panel sidebar, with the version on it
The panel sidebar is regrouped: Logs (audit log, email delivery), Developers (API tokens, operator MCP, your requests), workspace settings at the bottom, and an account menu for security, theme and sign-out. The Rekey version sits beside the logo. When the panel and API run different versions it shows both, for example v2.2.1 · API v2.2.0, which makes a half-finished upgrade easy to spot.
Security and correctness fixes
- Money path. Five checkout and renewal cases now end in the right state: a resubscribe to a plan canceled earlier, a Stripe renewal whose payment lands before the period update, a refused checkout that held a coupon slot, a retried trial completion, and a late invoice for an older period.
- Erasure. Erasing a person now also removes their address from organization invitations and the webhook deliveries about them.
- Passkeys. Starting a passkey sign-in no longer reveals whether an address has an account.
- Panel sessions. A request from another site can no longer use up an operator's refresh token and sign them out everywhere.
- Admin sign-in. The admin app's sign-in form now runs the same request-origin check as the rest of the admin app.
ENCRYPTION_KEY. A key that is not 64 hex characters is refused at boot, instead of failing the first time a credential is saved. Such a key could never encrypt anything, so nothing stored is lost. Generate one withopenssl rand -hex 32.- SMTP. The API now uses nodemailer 10, which clears the advisories reported against the older version.
- Billing log. A billing event queued in the same millisecond as a drain is no longer held until the next poll.
Where to go next
- Build an onboarding sequence with scheduled sends and topics.
- Add a delete-account button with the self-service deletion guide.
- New to custom emails? Start with custom email templates.
Was this page helpful?
