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:
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) andConfigService. - Each public method wraps
lastValueFrom(this.http.post(...))with provider-specific signing. - Errors throw
ApiExceptionwith anApiCodevalue — seeapps/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.mdfor the test DB setup).
Run with:
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:
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¶
- Local smoke test. Curl the webhook with a known-good signature (provider's docs supply a sample payload):
- Database. Confirm a
Paymentrow was created withproviderId="YOURPROVIDER". - Jaeger. Search service
ebit-api, operationPOST /payments/integration/yourprovider/webhook. The trace should include the verification step + the settlement DB writes. - Postman. Open
docs/api/postman/ebit-api.postman_collection.jsonin Postman; the new webhook should appear under the "Webhooks" or auto-tagged folder.
Cross-links¶
integration-cookbook.md— index.docs/api-reference/api.md→ "Webhooks" tag.docs/data-model/—Payment,PaymentProvider,PaymentProviderNameenum.docs/env-reference.md— Doppler conventions.docs/api/sync-postman.sh— SOT refresh.- Existing examples:
apps/api/src/payment/provider/integration/{ccpayment,nowpayments,skindeck}/.