I’m building VenloRent, a property marketplace for Nigeria, on React, Node, Express and MongoDB. Subscriptions run through Bachs, with settlement in NGN across three plans: Free, Pro and Premium.
When I finished the billing integration, I realised I hadn’t just added payments. I’d built a small data connector: a pipeline that takes data owned by an external system and keeps it faithfully mirrored against the right person in mine. The same shape applies to any API.
The connector pattern
Every connector I’ve seen reduces to the same steps:
Map identity: know whose data this is
Initiate: start the flow the user authorises
Receive: get the data (push via webhook, or pull via polling)
Verify: prove it came from who it claims
Normalise: turn their shape into yours
Sync idempotently: make sure replays and out-of-order events don’t corrupt state
Here is each step in VenloRent.
- Map identity first
Everything else depends on this. Each VenloRent user gets a bachsCustomerId, and every event that comes back is matched to a user through it, never by email or guesswork.
- Initiate with an authenticated endpoint
// POST /billing/checkout-session (auth required)
const product = PLAN_PRODUCTS[req.body.plan]; // allow-list, never trust client input
if (!product) return res.status(400).json({ error: 'Unknown plan' });
const customerId = await getOrCreateBachsCustomer(req.user);
const session = await bachs.checkout.create({
customer: customerId,
product,
billing: 'subscription',
settlement: 'NGN',
});
res.json({ checkoutUrl: session.checkoutUrl });
The client sends only a plan name (pro). The server decides what that costs and which product it maps to. The user can’t pick their own price.
- Receive and verify the webhook
Express will happily parse your JSON body before you can check the signature, and a re-serialised body won’t match what was signed. The webhook route needs the raw bytes:
app.post('/billing/webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
const timestamp = req.header('x-bachs-timestamp');
const signature = req.header('x-bachs-signature');
if (!verifyBachsSignature(req.body, timestamp, signature)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString('utf8'));
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
await syncSubscription(event.data);
break;
case 'customer.subscription.deleted':
await downgradeToFree(event.data);
break;
}
res.sendStatus(200);
}
);
A verification helper typically looks like this:
// [VERIFY against Bachs's docs: signed payload format and algorithm]
const crypto = require('crypto');
function verifyBachsSignature(rawBody, timestamp, signature) {
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!timestamp || age > 300) return false; // reject stale or replayed requests
const expected = crypto
.createHmac('sha256', process.env.BACHS_WEBHOOK_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Two details matter here. The timestamp check stops someone replaying a captured valid request later, and timingSafeEqual avoids leaking the signature through response timing.
- Normalise and sync
The provider’s event becomes my user’s state:
async function syncSubscription(sub) {
const user = await User.findOne({ bachsCustomerId: sub.customer });
if (!user) return; // unknown customer: log it, don't crash
user.plan = planFromProduct(sub.product); // 'free' | 'pro' | 'premium'
user.subscription = {
// [replace with your actual subscription.* fields]
status: sub.status,
currentPeriodEnd: sub.currentPeriodEnd,
};
await user.save();
}
This is where the “connector” part lives: translating their vocabulary (product IDs, statuses) into mine (user.plan).
- Remember that the data arrives late
The user pays and gets redirected back to my app before the webhook has landed. If the frontend trusts the redirect, it shows the wrong plan. So the settings page polls the user’s plan after redirect and only confirms success when the plan actually changes (or shows “cancelled” if it doesn’t).
The same applies to any push-based connector: the source of truth updates asynchronously, so your UI has to treat that gap as a normal state.
What I’d still harden
These are the gaps between what I built and a production-grade connector:
Idempotency: store processed event IDs so a retried webhook can’t apply twice
Ordering: compare timestamps or versions so a stale updated event can’t overwrite a newer one
Reconciliation: a periodic job that pulls the provider’s state and fixes drift when a webhook is missed
Observability: log unmatched customers and failed verifications
Where this connects to user-owned data
This connector moves data from a provider into my database, and the user authorises it only indirectly.
Top comments (0)