Skip to content

Add a new payment provider

Goal: wire a new payment provider into Evospin (analogous to CCPAYMENT, NOWPAYMENTS, SKINDECK). Audience: customer engineering team integrating a custodian, exchange, or payment gateway not currently shipped. Time: 1–2 days code + 1–3 weeks integration testing against the provider sandbox. Friction: medium — the abstraction exists by convention, not by interface.

What you'll change

Layer Path Action
Module shell apps/api/src/payment/provider/integration/<name>/ New folder. Copy from ccpayment/ as the canonical example.
Module wiring apps/api/src/payment/provider/payment-provider.module.ts Add <Name>Module to imports.
Webhook controller apps/api/src/payment/provider/integration/<name>/<name>-webhook.controller.ts Path: payments/integration/<name>/webhook (matches CCPayment).
Provider enum libs/_prisma/src/schema/api.prisma:93 (enum PaymentProviderName) Add a value. Run a migration.
Doppler / env .example.env (and Doppler) Add <NAME>_API_URL, <NAME>_API_KEY, <NAME>_WEBHOOK_SECRET.
OpenAPI (auto) Re-run docs/api/sync-postman.sh after the new webhook controller is in place.

Canonical example

apps/api/src/payment/provider/integration/ccpayment/ is the cleanest reference:

ccpayment/
├── ccpayment.module.ts                # Nest module
├── ccpayment.service.ts               # outbound calls (deposit address, withdrawal)
├── ccpayment-webhook.controller.ts    # POST /payments/integration/ccpayment/webhook
├── ccpayment-webhook.service.ts       # webhook signature verification + dispatch
├── const.ts                           # endpoints, signing version
├── dto/                               # request/response shapes
├── utils.ts                           # crypto helpers (HMAC, request id)
└── __tests__/                         # provider-specific Vitest specs

Read ccpayment.module.ts first — it imports HttpAgentModule (shared HTTP client with retry) and NetworkModule (cross-provider network/chain mapping).

Steps

1. Add the Prisma enum value [non-engineering — needs DB migration window]

Edit libs/_prisma/src/schema/api.prisma:

enum PaymentProviderName {
  CCPAYMENT
  NOWPAYMENTS
  SKINDECK
  // NEW: must match the runtime constant you'll use in code.
  YOURPROVIDER

  TEST

  @@map("payment_provider_name")
  @@schema("public")
}

Generate a migration:

cd ebit-api
npm run db:migrate:dev -- --name add_yourprovider_payment_enum

Verify the migration is non-blocking — a new enum value is an additive Postgres operation.

2. Add the env keys

Edit .example.env:

YOURPROVIDER_API_URL="https://sandbox.yourprovider.example/v1"
YOURPROVIDER_API_KEY=""
YOURPROVIDER_WEBHOOK_SECRET=""

Set the real values in Doppler for each environment (local, staging, prod). See docs/env-reference.md for the Doppler conventions.

3. Scaffold the integration module

Copy the CCPayment folder structure:

cp -r apps/api/src/payment/provider/integration/ccpayment \
      apps/api/src/payment/provider/integration/yourprovider
# rename files: yourprovider.module.ts, yourprovider.service.ts, yourprovider-webhook.controller.ts, …

Adapt the module:

// apps/api/src/payment/provider/integration/yourprovider/yourprovider.module.ts
import { HttpAgentModule } from '@api/payment/provider/integration/http-agent/http-agent.module';
import { NetworkModule } from '@api/payment/provider/network/network.module';
import { Module } from '@nestjs/common';
import { YourProviderService } from './yourprovider.service';
import { YourProviderWebhookController } from './yourprovider-webhook.controller';
import { YourProviderWebhookService } from './yourprovider-webhook.service';

@Module({
  imports: [HttpAgentModule, NetworkModule],
  controllers: [YourProviderWebhookController],
  providers: [YourProviderWebhookService, YourProviderService],
  exports: [YourProviderService, YourProviderWebhookService],
})
export class YourProviderModule {}

4. Wire the new module into PaymentProviderModule

Edit apps/api/src/payment/provider/payment-provider.module.ts:

import { YourProviderModule } from '@api/payment/provider/integration/yourprovider/yourprovider.module';

@Module({
  imports: [
    CCPaymentModule,
    NowpaymentsModule,
    SkinDeckModule,
    YourProviderModule,    // <-- add
    WalletModule,
    NetworkModule,
  ],
  
})
export class PaymentProviderModule {}

This is the central wiring point. There is no PaymentProviderInterface and no auto-discovery — every provider is hand-wired here. (See the cookbook's "friction map" — this is recipe 1's medium-friction step.)

5. Implement the outbound service

Use apps/api/src/payment/provider/integration/ccpayment/ccpayment.service.ts as the canonical pattern:

  • Constructor injects HttpService (from @nestjs/axios) and ConfigService.
  • Each public method wraps lastValueFrom(this.http.post(...)) with provider-specific signing.
  • Errors throw ApiException with an ApiCode value — see apps/api/src/payment/provider/exception/.

6. Implement the webhook controller

Pattern from ccpayment-webhook.controller.ts:20-25:

@Controller('payments/integration/yourprovider')
export class YourProviderWebhookController {
  constructor(private readonly service: YourProviderWebhookService) {}

  @Post('webhook')
  async webhook(@Body() body: YourProviderWebhookDto, @Req() req: Request) {
    return this.service.handle(body, req);
  }
}

The webhook service must: 1. Verify the signature before any state mutation. Use a constant-time comparison (crypto.timingSafeEqual). 2. Idempotency — store the provider's transaction id and refuse re-processing. CCPayment uses Payment table's providerTransactionId. 3. Settle the deposit through PaymentProviderService so accounting/aggregates stay correct.

7. Add tests

Mirror apps/api/src/payment/provider/integration/ccpayment/__tests__/:

  • One spec for the outbound service (mocked HTTP).
  • One spec for the webhook signature verification (positive + negative cases).
  • One spec for end-to-end deposit settlement (uses the test database — see docs/recipes/add-prisma-model.md for the test DB setup).

Run with:

npm test -- apps/api/src/payment/provider/integration/yourprovider/

8. Observability

The provider's outbound HTTP calls are auto-instrumented by @opentelemetry/instrumentation-http — you'll see HTTP POST <provider>.example spans in Jaeger without writing any tracing code.

For higher-fidelity tracing of the provider call (recommended), wrap the public service method in a manual span:

import { trace } from '@opentelemetry/api';

async createDepositAddress(userId: string) {
  const tracer = trace.getTracer('payment.yourprovider');
  return tracer.startActiveSpan('yourprovider.createDepositAddress', async (span) => {
    try {
      span.setAttribute('user_id', userId);
      const result = await this.http... ;
      return result;
    } finally {
      span.end();
    }
  });
}

See docs/recipes/add-otel-span.md for the full pattern.

9. Update the API surface SOT

After the new webhook controller is in place:

cd /home/ubuntu/ebit
./docs/api/sync-postman.sh

This re-pulls swagger.json, regenerates docs/api/postman/ebit-api.postman_collection.json, and writes a diff. Append a docs/api/changelog.md entry under "New endpoints" with the webhook path.

Verification

  1. Local smoke test. Curl the webhook with a known-good signature (provider's docs supply a sample payload):
    curl -X POST http://localhost:4000/payments/integration/yourprovider/webhook \
      -H 'X-Provider-Signature: <sample>' \
      -H 'Content-Type: application/json' \
      -d @sample-payload.json
    
  2. Database. Confirm a Payment row was created with providerId="YOURPROVIDER".
  3. Jaeger. Search service ebit-api, operation POST /payments/integration/yourprovider/webhook. The trace should include the verification step + the settlement DB writes.
  4. Postman. Open docs/api/postman/ebit-api.postman_collection.json in Postman; the new webhook should appear under the "Webhooks" or auto-tagged folder.