Every SaaS that touches email eventually gets the same support ticket: "my emails are going to spam — please fix." You can either absorb that as a T2 cost centre forever, or you can surface placement data inside your product and let users diagnose themselves. This article walks through the second option end to end: API wiring, tenant isolation, branding, webhooks and billing.
ESPs, CRMs, cold-outreach platforms, marketing-automation tools, review-request tools — any product that sends email on behalf of tenants. If your users never see their own SPF/DKIM status, they will assume a spam issue is your fault. A placement widget moves that conversation from "fix my email" to "here is what your DNS says."
When this integration makes sense
There are three product shapes where an inbox-placement widget pays for itself in a quarter:
- ESPs and marketing-automation tools. Your tenants own their own sending domains. You want a "test this campaign before send" button next to the schedule dialog.
- Cold-outreach platforms. Tenants rotate through many domains. They need a pre-flight check for every new domain before running a sequence.
- CRMs and support tools. Transactional email is invisible until it breaks. A quiet background check every week tells your customer success team which accounts are one SPF fix away from churning.
If your product never sends email on behalf of users, this is not the feature for you. If it does, read on.
Architecture sketch
Three moving parts. Your backend holds one Inbox Check API key. Users talk to your backend; your backend talks to Inbox Check. Webhook deliveries come back to your backend and fan out to the right tenant. No tenant ever sees the Inbox Check key, URL, or request headers.
[browser tenant UI]
|
v
[your backend] --Bearer ic_live_xxx--> [check.live-direct-marketing.online/api]
^
| HMAC-signed webhooks
+------------------------------ [check.live-direct-marketing.online]The reason is compliance. Inbox Check keys are billing-scoped to your company, not your tenants. If you exposed them client-side, anyone could read-out the key from DevTools and run tests on your quota.
The tenant-to-API-key mapping problem
You have one Inbox Check API key. You have N tenants. Two ways to keep them separate:
Option A: tag every test with a tenant ID
Pass metadata.tenantId on every POST /api/tests. The field round-trips back on the result and the webhook. Use it to route back to the right tenant in your database.
// your-backend: POST /internal/deliverability/test
await fetch('https://check.live-direct-marketing.online/api/tests', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.INBOX_CHECK_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: tenant.fromAddress,
subject: req.body.subject,
html: req.body.html,
metadata: { tenantId: tenant.id, campaignId: req.body.campaignId },
}),
});Option B: one sub-key per tenant
If your plan supports sub-keys, mint one per tenant and keep it in your tenant config row. More secure (a compromised sub-key blast-radius is one tenant, not all of them) and gives you per-tenant rate-limit partitioning for free. More operational surface area.
Most integrations start with A and graduate to B once they have more than a few hundred tenants.
Proxying requests (server-side only)
Your frontend calls your API, never the Inbox Check API directly. Here is the minimal Express proxy:
// routes/deliverability.ts
router.post('/tests', requireAuth, async (req, res) => {
const tenant = req.user.tenant;
// 1. Enforce tenant quota BEFORE calling upstream
if (tenant.testsThisMonth >= tenant.plan.testsPerMonth) {
return res.status(402).json({ error: 'quota_exhausted' });
}
// 2. Call upstream with OUR key
const upstream = await fetch('https://check.live-direct-marketing.online/api/tests', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.INBOX_CHECK_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: tenant.fromAddress,
subject: req.body.subject,
html: req.body.html,
metadata: { tenantId: tenant.id },
}),
});
const { data } = await upstream.json();
// 3. Record the test against the tenant
await db.tests.insert({ tenantId: tenant.id, externalId: data.id });
await db.tenants.increment(tenant.id, 'testsThisMonth');
res.json({ id: data.id, status: data.status });
});Displaying results — what to show, what to hide
Tenants want the headline number and one action per problem. Do not dump the raw JSON. A useful widget has three sections:
- Placement ring. Inbox / Spam / Missing, one line each, big number, per-provider breakdown on hover.
- Authentication trio. SPF pass/fail, DKIM pass/fail, DMARC pass/fail — each with a one-line action if it fails ("add include:sendgrid.net to SPF").
- Content score. SpamAssassin / Rspamd score with the top 3 contributing rules, plain-English.
Hide: internal seed-mailbox identifiers, raw DNS responses, per-seed timestamps, tracking-domain detail. These are debugging signals for you, not the tenant.
Branding the response
The API response has no visual assets, just data. Brand it yourself: colours, tooltips, empty-states. A few small things make a big difference:
- Localise authentication failure messages to your product voice. "Your SPF record is missing" reads better than
spf=fail. - Swap DNS record snippets for deep-links to your own DNS editor when possible.
- Never expose the word "Inbox Check" in tenant UI unless your plan is marked white-label in the dashboard. The terms of service make this explicit.
Handling webhook fan-out to tenants
The webhook lands on your backend. Verify the HMAC signature, look up the test by external ID, find the tenant, route the notification through your own channels (email, in-app, Slack integration, whatever you offer).
// POST /webhooks/inbox-check
app.post('/webhooks/inbox-check',
express.raw({ type: 'application/json' }),
async (req, res) => {
// 1. Verify signature
const sig = req.header('x-inbox-check-signature');
const expected = crypto
.createHmac('sha256', process.env.INBOX_CHECK_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.sendStatus(401);
}
// 2. Find tenant via metadata we sent
const event = JSON.parse(req.body);
const tenantId = event.data.metadata?.tenantId;
if (!tenantId) return res.sendStatus(200);
// 3. Fan out
await notifyTenant(tenantId, event);
res.sendStatus(200);
},
);Usage accounting and billing
You are on the hook for the bill. Your tenants are on the hook for whatever you charge them. The simplest model: meter tests per tenant per month, include N in the base plan, charge overage. Record every test ID in your own database and reconcile against the Inbox Check invoice at month-end.
Common gotcha: a tenant triggers a test, then refreshes the page and triggers another. Deduplicate on the client (disable the button) and on the server (idempotency key scoped to tenant-minute). Otherwise you will pay for the same test three times.
A single runaway tenant can eat your whole per-minute limit. Enforce a per-tenant limit in your proxy — something like 10 tests per minute per tenant — before calling upstream. This way one tenant's bad Zapier loop does not starve every other tenant's placement widget.
Caching
Two cacheable things. GET /api/me — cache it for five minutes, it changes on plan updates only. The list of supported seed providers — cache it for a day. Everything else is per-test and should be fetched fresh.
Do not cache test results across tenants even if the content is identical — the placement varies by sending domain and the results belong to whoever paid for them. Cross-tenant caching is a data-leak bug waiting to happen.