Get started in 15 minutes
Integrate password reset, 2FA, and magic links without building complex auth infrastructure. This guide walks through the complete integration.
Sign up and get your API key
Create a free account and generate your first API key. No card required, and no trial clock right now.
Add the widget to your page
Drop a single script tag on your forgot password page. Works with any framework or plain HTML.
Implement two webhooks
One to verify users exist, one to confirm password updates. ResetKit handles everything else.
Installation
Option 1: NPM Package (Recommended)
Install via npm for TypeScript support and bundler integration:
npm install @resetkit/widget
Then initialize in your code:
import ResetKit from '@resetkit/widget' ResetKit.init({ apiKey: 'rk_live_...', container: '#reset-form' });
Option 2: Script Tag
For vanilla HTML or when you don't want to use a bundler:
<!-- Add to your <head> --> <script src="https://cdn.resetkit.dev/widget.js"></script> <!-- Initialize in your page --> <script> ResetKit.init({ apiKey: 'rk_live_...', container: '#reset-form' }); </script>
Webhook Setup
ResetKit needs two webhooks to communicate with your backend. Both are simple POST endpoints that return JSON.
/webhook/verify-user
Called when a user requests a password reset. Return whether the email exists in your database.
Request body:
{
"email": "user@example.com"
}Expected response:
{
"exists": true
}Example implementation:
app.post('/webhook/verify-user', async (req, res) => { const user = await db.users.findOne({ email: req.body.email }); res.json({ exists: !!user }); });
/webhook/reset-complete
Called after user successfully verifies their identity. Update their password in your database.
Request body:
{
"email": "user@example.com",
"password": "newSecurePassword123",
"sessionId": "abc123..."
}Expected response:
{
"success": true
}Example implementation:
app.post('/webhook/reset-complete', async (req, res) => { const { email, password } = req.body; const hashed = await bcrypt.hash(password, 12); await db.users.updateOne( { email }, { $set: { password: hashed } } ); res.json({ success: true }); });
Security note: Webhook requests include an HMAC signature in the X-ResetKit-Signature header. Verify this signature to ensure requests are genuinely from ResetKit. See the security docs for implementation details.
Configuration Options
| Option | Type | Description |
|---|---|---|
| apiKey | string | Your ResetKit API key (required) |
| container | string | CSS selector for widget mount point (required) |
| brandColor | string | Primary color for buttons and links (optional) |
| returnUrl | string | Where to redirect after successful reset (optional) |
| onSuccess | function | Callback fired on successful reset (optional) |
| onError | function | Callback fired on error (optional) |
Paying with Crypto
We take Bitcoin, Ethereum and Solana. Payments are verified against the public chain and applied on their own, with no payment processor in between.
How it works
- Open Billing in your dashboard and choose a plan, then pick a chain. You get an address and an exact amount.
- Send that amount from any wallet or exchange. Nothing to copy back afterwards.
- Once the transaction confirms, your plan activates on its own, usually within a couple of minutes.
The quoted amount is priced at the moment you request it and holds for fifteen minutes. Request a new quote after that and the amount is recalculated.
Send the exact amount
The amount is how we tell your payment apart from everyone else's, so send what the quote says. A small shortfall from price movement is fine and still counts. A significantly different amount cannot be matched automatically and needs a hand.
Send only the coin named on the quote, on its own network. A different asset, or the right asset on a bridged or wrapped network, will not be seen.
Terms and renewal
- • A payment buys 31 days, not a subscription. Nothing can charge you again.
- • Paying early extends your current term rather than restarting it.
- • After a term ends, service continues unchanged for another 14 days.
- • After that the account drops to trial limits. Nothing is deleted, and paying restores it immediately.
- • We email twice about a renewal, once a week ahead and once when the term ends. Then we stop.
That is 45 days from payment before anything about your account changes.
What we keep
We do not store your transaction identifier, the on-chain amount, or anything else that would connect your ResetKit account to a wallet. We keep a one-way reference that stops the same payment being counted twice, and the plan price in dollars. A public ledger never forgets, so the only way to keep that link private is not to hold it.
If something looks wrong
Give it a few minutes first. Confirmation times vary by network and by how busy it is, and nothing happens on our side until the transaction confirms. If your plan has not changed after that, paste the transaction hash on the billing page, which is the manual path and still works. Still stuck, email support@resetkit.dev with the chain and the amount you sent. Your payment is safe either way; it is on the chain whether or not our side has caught up.
Sign-in Links
Passwordless sign-in for your own application. You ask us to mail a link, the person clicks it, and we hand them back to you with a signed statement that they opened mail sent to that address.
What we do and do not do
We prove one thing: somebody opened mail sent to an address. That is the entire claim, and it is all a sign-in link can honestly establish.
We never create a session for you. Sessions belong on your domain, under your rules about how long they last and what they permit, tied to your own user records. We tell you who just proved control of a mailbox. Deciding whether that person has an account, and what they are allowed to do, stays with you.
Before you start
- 1. Create a connection in your dashboard and copy its API key and webhook secret.
- 2. Set the connection's returnUrl to the route in your app that will receive people after they click. This is the only place we will ever send them.
- 3. Make sure the connection has the magic:create scope. If it does not, the API returns 403 and names the missing scope.
- 4. Keep the webhook secret on your server. It is the key that makes an assertion trustworthy, and anyone holding it can forge one.
Step 1: Ask us to send the link
Call this from your backend when somebody enters their email address.
curl -X POST https://api.resetkit.dev/api/v1/magic/signin -H "x-resetkit-key: rk_live_your_api_key" -H "Content-Type: application/json" -d '{"email":"person@example.com"}'The response is the same whether or not that address has ever been seen before. This endpoint is called with whatever a stranger typed into a login box, so telling you apart from a stranger would let anyone test which addresses have accounts.
Step 2: Verify what comes back
We redirect to your returnUrl with two query parameters, rk_payload and rk_signature. Check the signature before you trust anything in the payload.
const crypto = require('crypto');
function verifyResetKitAssertion(payload, signature, webhookSecret) {
const expected = crypto
.createHmac('sha256', webhookSecret)
.update(payload)
.digest('hex');
// Compare in constant time. timingSafeEqual throws when lengths
// differ, so check the length first rather than letting it throw.
const a = Buffer.from(String(signature));
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return { ok: false, reason: 'bad signature' };
}
// Only now is it safe to read the payload. Parsing it earlier would
// mean making decisions from data the visitor could have written.
const claim = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
if (Math.floor(Date.now() / 1000) > claim.expiresAt) {
return { ok: false, reason: 'expired' };
}
return { ok: true, email: claim.email, nonce: claim.nonce };
}The payload is readable by whoever is holding the link, so nothing may depend on them not editing it. Changing a single character of the address breaks the signature.
Step 3: Sign them in
app.get('/auth/callback', async (req, res) => {
const result = verifyResetKitAssertion(
req.query.rk_payload,
req.query.rk_signature,
process.env.RESETKIT_WEBHOOK_SECRET
);
if (!result.ok) return res.redirect('/login?error=' + result.reason);
// Refuse a nonce you have already accepted. The window is short, but
// a link can be opened twice inside it.
if (await nonceAlreadyUsed(result.nonce)) {
return res.redirect('/login?error=replay');
}
await recordNonce(result.nonce);
// Your users, your rules. Look them up or create them, then issue
// whatever session your app normally issues.
const user = await findOrCreateUser(result.email);
await startSession(res, user);
res.redirect('/');
});Worth knowing
- • A link works once. Clicking it a second time, or a mail client fetching it in the background, will not produce a second assertion.
- • The link itself lasts 15 minutes. The assertion it produces lasts 60 seconds, which is long enough for a redirect and short enough that a URL sitting in browser history or a referrer header is worthless.
- • We only ever redirect to the return URL stored on the connection. Nothing in the request can change where somebody lands.
- • Requests are rate limited per address and per account, so a login form cannot be used to mail somebody repeatedly.
- • Rotate the webhook secret and every previously issued assertion stops verifying, which is the intended behaviour.