Integrating Conekta Sandbox Payments for Condo Fee Quotas in a NestJS Monorepo
TL;DR: Added a full Conekta sandbox integration (service, webhook controller, DB migration, and tests) to enable condo‑fee payments. The change introduces secure webhook verification, stores payment references, and makes the feature toggleable via environment variables.
The Problem
Our API needed a way to collect monthly condo‑fee payments online. The existing system only recorded fees internally; there was no external payment gateway. When we tried to call Conekta’s sandbox endpoint with a hard‑coded key we hit two blockers:
-
Missing environment variables –
CONEKTA_PRIVATE_KEYandCONEKTA_WEBHOOK_PUBLIC_KEYwere not defined in the Docker compose for the sandbox, causing a runtime error:
Error: Environment variable CONEKTA_PRIVATE_KEY is not defined
-
No webhook verification – Conekta sends a signed POST to our
/conekta/webhookendpoint. Without signature validation we risked processing forged events, and our logs were filled with “Invalid webhook signature” warnings.
The goal was to expose a clean, testable API that creates a payment charge, receives the webhook, verifies it, and stores the payment_ref on the condo_fees table.
What I Tried First
My first attempt was to use the Conekta Node SDK directly inside condo-fees.controller.ts. I added:
import Conekta from 'conekta';
Conekta.apiKey = process.env.CONEKTA_PRIVATE_KEY!;
and called Conekta.Order.create(...). Two issues surfaced:
- The SDK expects a production key format; the sandbox key (
key_test_...) triggered a 401 error. - The SDK bundled its own webhook verification that relied on a global config, which conflicted with our multi‑tenant architecture.
I rolled back the SDK usage and switched to a lightweight fetch‑based client, but I still didn’t have a reliable way to verify the webhook signature. I also realized that the condo_fees table lacked a column to store the external reference, so any successful charge would be lost after the request completed.
The Implementation
Below is the final, production‑ready implementation. All new files live under apps/api/src/conekta/, and the existing modules were updated accordingly.
1. Environment Schema (apps/api/src/common/env.ts)
We made the Conekta keys optional (the service will throw if they’re missing in sandbox mode) and documented them:
// apps/api/src/common/env.ts
const envSchema = z.object({
// ... existing vars
// ── Conekta — OPTIONAL
CONEKTA_PRIVATE_KEY: z.string().optional(),
CONEKTA_WEBHOOK_PUBLIC_KEY: z.string().optional(),
});
2. Database Migration (apps/api/src/db/db.ts)
Added two nullable columns to keep the external reference and webhook status:
// apps/api/src/db/db.ts
export async function migrate(): Promise<void> {
// ... previous migrations
await query(`
ALTER TABLE condo_fees
ADD COLUMN IF NOT EXISTS payment_ref TEXT NULL,
ADD COLUMN IF NOT EXISTS payment_status TEXT NULL;
`);
}
3. Conekta Service (apps/api/src/conekta/conekta.service.ts)
A pure NestJS provider that handles charge creation and webhook signature verification.
// apps/api/src/conekta/conekta.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { env } from '../common/env.js';
import * as crypto from 'node:crypto';
import fetch from 'node-fetch';
@Injectable()
export class ConektaService {
private readonly logger = new Logger('ConektaService');
private readonly apiKey = env.CONEKTA_PRIVATE_KEY;
private readonly webhookPubKey = env.CONEKTA_WEBHOOK_PUBLIC_KEY;
private readonly apiUrl = 'https://api.conekta.io';
async createCharge(amount: number, email: string, description: string) {
if (!this.apiKey) {
throw new Error('CONEKTA_PRIVATE_KEY not set');
}
const payload = {
currency: 'MXN',
amount,
description,
reference_id: `condo-${Date.now()}`,
customer_info: { email },
payment_method: {
type: 'card',
token_id: 'tok_test_visa_4242', // sandbox token
},
};
const resp = await fetch(`${this.apiUrl}/charges`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.text();
this.logger.error(`Conekta charge failed: ${err}`);
throw new Error(`Conekta charge error: ${resp.status}`);
}
return resp.json();
}
// Conekta signs the raw request body with HMAC SHA256
verifyConektaWebhookSignature(rawBody: Buffer, signature: string): boolean {
if (!this.webhookPubKey) {
this.logger.warn('CONEKTA_WEBHOOK_PUBLIC_KEY not set – skipping verification');
return false;
}
const expected = crypto
.createHmac('sha256', this.webhookPubKey)
.update(rawBody)
.digest('hex');
const isValid = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
if (!isValid) {
this.logger.warn('Invalid Conekta webhook signature');
}
return isValid;
}
}
4. Webhook Controller (apps/api/src/conekta/conekta-webhook.controller.ts)
Receives POSTs from Conekta, validates the signature, and updates the condo_fees row.
// apps/api/src/conekta/conekta-webhook.controller.ts
import {
Controller,
Post,
Req,
Res,
HttpCode,
Body,
Headers,
} from '@nestjs/common';
import { ConektaService } from './conekta.service.js';
import { query as db } from '../db/db.js';
@Controller('conekta')
export class ConektaWebhookController {
constructor(private readonly conekta: ConektaService) {}
@Post('webhook')
@HttpCode(200)
async handle(
@Req() req: any,
@Res() res: any,
@Headers('Conekta-Signature') signature: string,
) {
const rawBody = req.rawBody as Buffer; // enabled via Nest's raw-body middleware
if (!this.conekta.verifyConektaWebhookSignature(rawBody, signature)) {
return res.send({ status: 'invalid signature' });
}
const event = req.body;
if (event.type !== 'charge.paid') {
return res.send({ status: 'ignored' });
}
const { reference_id, id: paymentRef } = event.data.object;
await db(`
UPDATE condo_fees
SET payment_ref = $1,
payment_status = 'paid'
WHERE reference_id = $2
`, [paymentRef, reference_id]);
return res.send({ status: 'ok' });
}
}
Note: To expose
req.rawBodywe added `app.use(express.raw({
Part of my Build in Public series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.
Repo: zaerohell/VS · 2026-09-15
#playadev #buildinpublic
Top comments (0)