Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ DATABASE_URL=postgresql://user:password@localhost:5432/stellar_pay

# Redis (for when implemented)
REDIS_URL=redis://localhost:6379

# Webhooks
WEBHOOK_TIMEOUT_MS=5000
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AppService } from './app.service';
import { HealthModule } from './health/health.module';
import { TreasuryModule } from './treasury/treasury.module';
import { AuthModule } from './auth/auth.module';
import { WebhooksModule } from './webhooks/webhooks.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { ThrottlerRedisGuard } from './rate-limiter/guards/throttler-redis.guard';

Expand All @@ -14,6 +15,7 @@ import { ThrottlerRedisGuard } from './rate-limiter/guards/throttler-redis.guard
HealthModule,
TreasuryModule,
AuthModule,
WebhooksModule,
ThrottlerModule.forRoot({
throttlers: [
{ name: 'short', ttl: 60000, limit: 100 },
Expand Down
17 changes: 17 additions & 0 deletions apps/api/src/webhooks/interfaces/dispatch-webhook.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {
PaymentWebhookEventType,
WebhookDeliveryResult,
WebhookEndpoint,
WebhookEventPayload,
} from './webhook-event.interface';

export interface DispatchWebhookRequest<TData = unknown> {
event: PaymentWebhookEventType;
data: TData;
endpoints: WebhookEndpoint[];
}

export interface DispatchWebhookResponse<TData = unknown> {
payload: WebhookEventPayload<TData>;
deliveries: WebhookDeliveryResult[];
}
25 changes: 25 additions & 0 deletions apps/api/src/webhooks/interfaces/webhook-event.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export type PaymentWebhookEventType =
| 'payment.created'
| 'payment.detected'
| 'payment.confirmed'
| 'payment.failed';

export interface WebhookEndpoint {
url: string;
secret?: string;
}

export interface WebhookEventPayload<TData = unknown> {
event: PaymentWebhookEventType;
data: TData;
timestamp: string;
}

export interface WebhookDeliveryResult {
endpoint: string;
event: PaymentWebhookEventType;
timestamp: string;
ok: boolean;
status: number;
responseBody: string;
}
20 changes: 20 additions & 0 deletions apps/api/src/webhooks/webhooks.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Body, Controller, Post } from '@nestjs/common';
import { Public } from '../auth/decorators/public.decorator';
import type {
DispatchWebhookRequest,
DispatchWebhookResponse,
} from './interfaces/dispatch-webhook.interface';
import { WebhooksService } from './webhooks.service';

@Public()
@Controller('webhooks')
export class WebhooksController {
constructor(private readonly webhooksService: WebhooksService) {}

@Post('dispatch')
async dispatch<TData>(
@Body() body: DispatchWebhookRequest<TData>,
): Promise<DispatchWebhookResponse<TData>> {
return this.webhooksService.dispatchToEndpoints(body.endpoints, body.event, body.data);
}
}
10 changes: 10 additions & 0 deletions apps/api/src/webhooks/webhooks.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { WebhooksController } from './webhooks.controller';
import { WebhooksService } from './webhooks.service';

@Module({
controllers: [WebhooksController],
providers: [WebhooksService],
exports: [WebhooksService],
})
export class WebhooksModule {}
81 changes: 81 additions & 0 deletions apps/api/src/webhooks/webhooks.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { BadRequestException } from '@nestjs/common';
import { WebhooksService } from './webhooks.service';

describe('WebhooksService', () => {
let service: WebhooksService;

beforeEach(() => {
service = new WebhooksService();
process.env.WEBHOOK_TIMEOUT_MS = '5000';
});

afterEach(() => {
jest.restoreAllMocks();
delete process.env.WEBHOOK_TIMEOUT_MS;
});

it('creates a standardized payload', () => {
const payload = service.createPayload(
'payment.confirmed',
{ paymentId: 'pay_123', amount: '100.00' },
new Date('2026-03-25T10:00:00.000Z'),
);

expect(payload).toEqual({
event: 'payment.confirmed',
data: { paymentId: 'pay_123', amount: '100.00' },
timestamp: '2026-03-25T10:00:00.000Z',
});
});

it('signs webhook payloads when a secret is provided', () => {
const signature = service.createSignature('{"hello":"world"}', 'top-secret');

expect(signature).toHaveLength(64);
expect(signature).toMatch(/^[a-f0-9]+$/);
});

it('dispatches a webhook event to a merchant endpoint', async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
status: 202,
text: jest.fn().mockResolvedValue('accepted'),
});

global.fetch = fetchMock as typeof fetch;

const delivery = await service.dispatchEvent(
{ url: 'https://merchant.example/webhooks', secret: 'shared-secret' },
'payment.detected',
{ paymentId: 'pay_456', merchantId: 'm_123' },
);

expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
'https://merchant.example/webhooks',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'content-type': 'application/json',
'x-stellar-pay-event': 'payment.detected',
'x-stellar-pay-signature': expect.any(String),
'x-stellar-pay-timestamp': expect.any(String),
}),
}),
);
expect(delivery).toEqual({
endpoint: 'https://merchant.example/webhooks',
event: 'payment.detected',
timestamp: expect.any(String),
ok: true,
status: 202,
responseBody: 'accepted',
});
});

it('rejects empty endpoint lists', async () => {
await expect(
service.dispatchToEndpoints([], 'payment.created', { paymentId: 'pay_789' }),
).rejects.toBeInstanceOf(BadRequestException);
});
});
108 changes: 108 additions & 0 deletions apps/api/src/webhooks/webhooks.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { createHmac } from 'crypto';
import {
WebhookDeliveryResult,
WebhookEndpoint,
WebhookEventPayload,
PaymentWebhookEventType,
} from './interfaces/webhook-event.interface';

const PAYMENT_WEBHOOK_EVENTS: PaymentWebhookEventType[] = [
'payment.created',
'payment.detected',
'payment.confirmed',
'payment.failed',
];

@Injectable()
export class WebhooksService {
createPayload<TData>(
event: PaymentWebhookEventType,
data: TData,
date = new Date(),
): WebhookEventPayload<TData> {
this.ensureSupportedEvent(event);

return {
event,
data,
timestamp: date.toISOString(),
};
}

createSignature(payload: string, secret: string): string {
return createHmac('sha256', secret).update(payload).digest('hex');
}

async dispatchEvent<TData>(
endpoint: WebhookEndpoint,
event: PaymentWebhookEventType,
data: TData,
payload = this.createPayload(event, data),
): Promise<WebhookDeliveryResult> {
if (!endpoint.url) {
throw new BadRequestException('Webhook endpoint url is required');
}

const body = JSON.stringify(payload);
const headers: Record<string, string> = {
'content-type': 'application/json',
'user-agent': 'stellar-pay-webhooks/1.0',
'x-stellar-pay-event': payload.event,
'x-stellar-pay-timestamp': payload.timestamp,
};

if (endpoint.secret) {
headers['x-stellar-pay-signature'] = this.createSignature(body, endpoint.secret);
}

const response = await fetch(endpoint.url, {
method: 'POST',
headers,
body,
signal: AbortSignal.timeout(this.getTimeoutMs()),
});

return {
endpoint: endpoint.url,
event: payload.event,
timestamp: payload.timestamp,
ok: response.ok,
status: response.status,
responseBody: await response.text(),
};
}

async dispatchToEndpoints<TData>(
endpoints: WebhookEndpoint[],
event: PaymentWebhookEventType,
data: TData,
): Promise<{ payload: WebhookEventPayload<TData>; deliveries: WebhookDeliveryResult[] }> {
if (!Array.isArray(endpoints) || endpoints.length === 0) {
throw new BadRequestException('At least one webhook endpoint is required');
}

const payload = this.createPayload(event, data);
const deliveries = await Promise.all(
endpoints.map((endpoint) => this.dispatchEvent(endpoint, event, data, payload)),
);

return {
payload,
deliveries,
};
}

private ensureSupportedEvent(event: PaymentWebhookEventType) {
if (!PAYMENT_WEBHOOK_EVENTS.includes(event)) {
throw new BadRequestException(`Unsupported webhook event: ${event}`);
}
}

private getTimeoutMs(): number {
const rawTimeout = process.env.WEBHOOK_TIMEOUT_MS;
const timeout = Number.parseInt(rawTimeout ?? '5000', 10);

return Number.isNaN(timeout) || timeout <= 0 ? 5000 : timeout;
}
}
Loading