DEV Community

Roberto Luna
Roberto Luna

Posted on

Integrating Conekta Sandbox Payments for Condo Fee Quotas in a NestJS Monorepo

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:

  1. Missing environment variablesCONEKTA_PRIVATE_KEY and CONEKTA_WEBHOOK_PUBLIC_KEY were not defined in the Docker compose for the sandbox, causing a runtime error:
   Error: Environment variable CONEKTA_PRIVATE_KEY is not defined
Enter fullscreen mode Exit fullscreen mode
  1. No webhook verification – Conekta sends a signed POST to our /conekta/webhook endpoint. 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!;
Enter fullscreen mode Exit fullscreen mode

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(),
});
Enter fullscreen mode Exit fullscreen mode

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;
  `);
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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' });
  }
}
Enter fullscreen mode Exit fullscreen mode

Note: To expose req.rawBody we 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)