Product
This comprehensive guide will walk you through building a secure, scalable double opt-in (DOI) subscription flow using Mailjet’s Email API v3.1.
You’ll learn to implement signed confirmation links, email templates, webhooks, and contact synchronization to create a GDPR-compliant subscription system that improves deliverability and user engagement.
Double opt-in is a two-step subscription process where users first submit your form and then confirm their subscription via a link sent to their email inbox. Only after this confirmation are they added to your mailing list.
Approach | Steps | Signup volume | Quality | Compliance |
---|---|---|---|---|
Single Opt-In | One step | Higher | Lower (typos, bots) | Basic |
Double Opt-In | Two steps | Lower | Much higher | GDPR-ready |
By the end of this guide, you’ll have implemented:
Before starting, ensure you have:
Here’s how the double opt-in flow works:
Here’s a step-by-step guide using Mailjet’s email API.
Start by validating and storing the initial subscription request:
// Example validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return { error: 'Invalid email format' };
}
// Store pending subscription
await db.subscriptions.create({
email: email,
status: 'pending',
created_at: new Date(),
ip_address: req.ip
});
Security is critical; never use MD5 or simple hashes. Instead, implement proper token security:
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
function createConfirmationToken(email) {
const nonce = crypto.randomBytes(16).toString('hex');
const payload = {
email: email,
nonce: nonce,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (60 * 60) // 1 hour
};
return jwt.sign(payload, process.env.JWT_SECRET);
}
const token = createConfirmationToken('user@example.com');
const confirmUrl = `https://yourapp.com/confirm?token=${token}`;
Use Mailjet’s Send API v3.1 to deliver the confirmation email. You have two options:
First, create your template in Mailjet, then send:
curl -s -X POST \
--user "$MJ_APIKEY_PUBLIC:$MJ_APIKEY_PRIVATE" \
https://api.mailjet.com/v3.1/send \
-H 'Content-Type: application/json' \
-d '{
"Messages":[
{
"From":{"Email":"no-reply@yourdomain.com","Name":"Your Brand"},
"To":[{"Email":"recipient@example.com","Name":"Recipient"}],
"TemplateID": 123456,
"TemplateLanguage": true,
"Subject": "Please confirm your subscription",
"Variables": {
"confirm_url": "https://yourapp.com/confirm?token=...signed...",
"first_name": "Alex"
}
}
]
}'
curl -s -X POST \
--user "$MJ_APIKEY_PUBLIC:$MJ_APIKEY_PRIVATE" \
https://api.mailjet.com/v3.1/send \
-H 'Content-Type: application/json' \
-d '{
"Messages":[
{
"From":{"Email":"no-reply@yourdomain.com","Name":"Your Brand"},
"To":[{"Email":"recipient@example.com"}],
"Subject":"Confirm your subscription",
"TextPart":"Tap to confirm: {{var:confirm_url}}",
"HTMLPart":"<p>Almost there! <a href=\"{{var:confirm_url}}\">Confirm your subscription</a>.</p>",
"TemplateLanguage": true,
"Variables":{"confirm_url":"https://yourapp.com/confirm?token=..."}
}
]
}'
Your confirmation endpoint must securely verify tokens:
app.get('/confirm', async (req, res) => {
const { token } = req.query;
try {
// Verify token signature and expiration
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Check if token was already used
const existingUse = await db.used_tokens.findOne({
token_hash: crypto.createHash('sha256').update(token).digest('hex')
});
if (existingUse) {
return res.status(400).send('Token already used');
}
// Mark token as used
await db.used_tokens.create({
token_hash: crypto.createHash('sha256').update(token).digest('hex'),
used_at: new Date()
});
// Proceed with subscription
await subscribeToMailjet(decoded.email);
res.send('Subscription confirmed successfully!');
} catch (error) {
res.status(400).send('Invalid or expired confirmation link');
}
});
Once the token is verified, add the contact to your Mailjet list:
curl -s -X POST \
--user "$MJ_APIKEY_PUBLIC:$MJ_APIKEY_PRIVATE" \
https://api.mailjet.com/v3/REST/contact \
-H 'Content-Type: application/json' \
-d '{
"Email":"recipient@example.com",
"Name":"Recipient Name"
}'
curl -s -X POST \
--user "$MJ_APIKEY_PUBLIC:$MJ_APIKEY_PRIVATE" \
https://api.mailjet.com/v3/REST/listrecipient \
-H 'Content-Type: application/json' \
-d '{
"ContactAlt":"recipient@example.com",
"ListID": 123456
}'
curl -s -X PUT \
--user "$MJ_APIKEY_PUBLIC:$MJ_APIKEY_PRIVATE" \
https://api.mailjet.com/v3/REST/contactdata/recipient@example.com \
-H 'Content-Type: application/json' \
-d '{
"Data":[
{"Name":"first_name","Value":"Alex"},
{"Name":"city","Value":"Austin"}
]
}'
Note: Define custom contact properties first using the contactmetadata endpoint if they don’t already exist.
After successful subscription, send a welcome email message using the same Send API v3.1 pattern from Step 3.
Set up webhooks to receive real-time events for ongoing list hygiene:
curl -s -X POST \
--user "$MJ_APIKEY_PUBLIC:$MJ_APIKEY_PRIVATE" \
https://api.mailjet.com/v3/REST/eventcallbackurl \
-H 'Content-Type: application/json' \
-d '{
"EventType": "sent",
"Url": "https://yourapp.com/webhooks/mailjet"
}'
Track these events:
Here are some of the most common best practices.
Store the following information for GDPR compliance:
Got more questions about this process? Check out our FAQ.
Yes – create a “resend” endpoint that invalidates the old token and issues a new one with fresh expiration.
You can re-send confirmations. On click, use the listrecipient endpoint to ensure they’re on the correct list.
Query string is typical and easier to parse. Never log raw tokens in analytics.
Form Builder is excellent for quick launches. Use this API approach for customized experiences at scale.
With this implementation, you’ll have a robust, secure, and compliant double opt-in system that protects your sender reputation while building a high-quality subscriber base.