Skip to content
Merged
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
20 changes: 20 additions & 0 deletions app/backend/src/developer/developer.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { DeveloperService } from './developer.service';
import {
BulkRevokeDto,
BulkRevokeResultDto,
WebhookSampleEventDto,
WebhookTestResultDto,
IntegrationHealthDto,
PingResponseDto,
Expand Down Expand Up @@ -59,6 +60,25 @@ export class DeveloperController {
return this.developerService.testWebhook(webhookId);
}

@Post('webhooks/:webhookId/sample-events')
@HttpCode(HttpStatus.OK)
@RequireScopes('admin')
@ApiOperation({
summary: 'Send a canonical sample event to a webhook receiver',
description:
'Posts a sample link.created, payment.received, payment.settled, or payment.failed payload. ' +
'The request can include signature headers and an explicit timestamp for receiver testing.',
})
@ApiParam({ name: 'webhookId', description: 'Webhook UUID' })
@ApiResponse({ status: 200, type: WebhookTestResultDto })
@ApiResponse({ status: 404, description: 'Webhook not found' })
sampleWebhookEvent(
@Param('webhookId', ParseUUIDPipe) webhookId: string,
@Body() dto: WebhookSampleEventDto,
): Promise<WebhookTestResultDto> {
return this.developerService.sendSampleWebhookEvent(webhookId, dto);
}

@Post('keys/bulk-revoke')
@HttpCode(HttpStatus.OK)
@RequireScopes('admin')
Expand Down
111 changes: 99 additions & 12 deletions app/backend/src/developer/developer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { AuditService } from '../audit/audit.service';
import {
BulkRevokeDto,
BulkRevokeResultDto,
WebhookSampleEventDto,
WebhookSampleEventType,
WebhookTestResultDto,
IntegrationHealthDto,
PingResponseDto,
Expand Down Expand Up @@ -37,22 +39,37 @@ export class DeveloperService {
}

async testWebhook(webhookId: string): Promise<WebhookTestResultDto> {
return this.sendSampleWebhookEvent(webhookId, {
event_type: 'payment.received',
include_signature: true,
}, 'webhook.test');
}

async sendSampleWebhookEvent(
webhookId: string,
dto: WebhookSampleEventDto = {},
auditAction = 'webhook.sample',
): Promise<WebhookTestResultDto> {
const webhook = await this.webhookService.getWebhook(webhookId);
if (!webhook) throw new NotFoundException('Webhook not found');

const sentAt = new Date().toISOString();
const testEventId = `test_${crypto.randomUUID()}`;
const eventType = dto.event_type ?? 'payment.received';
const sentAt = dto.timestamp ?? new Date().toISOString();
const eventId = `sample_${eventType.replace('.', '_')}_${crypto.randomUUID()}`;
const payload = {
eventType: 'payment.received',
eventId: testEventId,
eventType,
eventId,
recipientPublicKey: webhook.publicKey,
payload: { test: true, source: 'developer_self_service_api' },
payload: this.buildSamplePayload(eventType, webhook.publicKey, sentAt),
timestamp: sentAt,
};

const bodyStr = JSON.stringify(payload);
const ts = Date.now();
const signature = this.signPayload(webhook.secret, bodyStr, ts);
const includeSignature = dto.include_signature ?? true;
const ts = new Date(sentAt).getTime();
const signature = includeSignature
? this.signPayload(webhook.secret, bodyStr, ts)
: undefined;

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TEST_WEBHOOK_TIMEOUT_MS);
Expand All @@ -67,9 +84,10 @@ export class DeveloperService {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-QX-Signature': signature,
'X-QX-Event': 'payment.received',
'X-QX-Event-Id': testEventId,
...(signature ? { 'X-QX-Signature': signature } : {}),
...(includeSignature ? { 'X-QX-Timestamp': String(ts) } : {}),
'X-QX-Event': eventType,
'X-QX-Event-Id': eventId,
'X-QX-Test': 'true',
'User-Agent': 'QuickEx-Webhook/1.0',
},
Expand Down Expand Up @@ -100,9 +118,16 @@ export class DeveloperService {

await this.auditService.log(
'developer_api',
'webhook.test',
auditAction,
webhookId,
{ target_url: webhook.webhookUrl, http_status: httpStatus, success, latency_ms: latencyMs },
{
target_url: webhook.webhookUrl,
http_status: httpStatus,
success,
latency_ms: latencyMs,
event_type: eventType,
signature_included: includeSignature,
},
);

return {
Expand All @@ -113,6 +138,9 @@ export class DeveloperService {
response_body: responseBody,
latency_ms: latencyMs,
sent_at: sentAt,
event_type: eventType,
event_id: eventId,
signature_included: includeSignature,
};
}

Expand Down Expand Up @@ -219,4 +247,63 @@ export class DeveloperService {
const hmac = crypto.createHmac('sha256', secret).update(signed).digest('hex');
return `t=${timestamp},v1=${hmac}`;
}

private buildSamplePayload(
eventType: WebhookSampleEventType,
recipientPublicKey: string,
timestamp: string,
): Record<string, unknown> {
const base = {
test: true,
source: 'developer_self_service_api',
schema_version: '2026-04-29',
};

switch (eventType) {
case 'link.created':
return {
...base,
link_id: 'plink_sample_01',
creator_public_key: recipientPublicKey,
asset: 'XLM',
amount: '25.0000000',
memo: 'QuickEx sample payment link',
expires_at: new Date(new Date(timestamp).getTime() + 86_400_000).toISOString(),
};
case 'payment.received':
return {
...base,
payment_id: 'pay_sample_received_01',
link_id: 'plink_sample_01',
from_public_key: 'GBZXN7PIRZGNMHGA6U2QBG7A5XBQ2YH6R3MGNJ2T63PXWKBUI5V3R2ZU',
to_public_key: recipientPublicKey,
asset: 'XLM',
amount: '25.0000000',
tx_hash: 'sample_received_tx_hash',
};
case 'payment.settled':
return {
...base,
payment_id: 'pay_sample_settled_01',
settlement_id: 'set_sample_01',
recipient_public_key: recipientPublicKey,
asset: 'XLM',
amount: '25.0000000',
fee_amount: '0.1000000',
settled_at: timestamp,
tx_hash: 'sample_settlement_tx_hash',
};
case 'payment.failed':
return {
...base,
payment_id: 'pay_sample_failed_01',
link_id: 'plink_sample_01',
recipient_public_key: recipientPublicKey,
asset: 'XLM',
amount: '25.0000000',
failure_code: 'INSUFFICIENT_BALANCE',
failure_message: 'Sample failure: sender balance was too low.',
};
}
}
}
51 changes: 50 additions & 1 deletion app/backend/src/developer/developer.service.unit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ describe('DeveloperService', () => {
expect(result.success).toBe(true);
expect(result.http_status).toBe(200);
expect(result.webhook_id).toBe('webhook-uuid-1234');
expect(result.event_type).toBe('payment.received');
expect(mockFetch).toHaveBeenCalledWith(
'https://example.com/hook',
expect.objectContaining({ method: 'POST' }),
Expand Down Expand Up @@ -165,6 +166,55 @@ describe('DeveloperService', () => {
});
});

// -------------------------------------------------------------------------
describe('sendSampleWebhookEvent', () => {
it('sends the requested canonical event type', async () => {
(mockWebhookService.getWebhook as jest.Mock).mockResolvedValue(makeWebhook());
const mockFetch = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
status: 200,
text: () => Promise.resolve('ok'),
} as unknown as Response);

const result = await service.sendSampleWebhookEvent('webhook-uuid-1234', {
event_type: 'payment.settled',
timestamp: '2026-04-29T12:00:00.000Z',
});

const [, request] = mockFetch.mock.calls[0];
const body = JSON.parse((request as RequestInit).body as string);

expect(result.event_type).toBe('payment.settled');
expect(result.signature_included).toBe(true);
expect(body.eventType).toBe('payment.settled');
expect(body.payload.settlement_id).toBe('set_sample_01');
expect((request as RequestInit).headers).toMatchObject({
'X-QX-Event': 'payment.settled',
'X-QX-Timestamp': String(new Date('2026-04-29T12:00:00.000Z').getTime()),
});
});

it('can omit signature headers for unsigned receiver tests', async () => {
(mockWebhookService.getWebhook as jest.Mock).mockResolvedValue(makeWebhook());
const mockFetch = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
status: 200,
text: () => Promise.resolve('ok'),
} as unknown as Response);

const result = await service.sendSampleWebhookEvent('webhook-uuid-1234', {
event_type: 'payment.failed',
include_signature: false,
});

const headers = (mockFetch.mock.calls[0][1] as RequestInit).headers as Record<string, string>;
expect(result.signature_included).toBe(false);
expect(headers['X-QX-Signature']).toBeUndefined();
expect(headers['X-QX-Timestamp']).toBeUndefined();
expect(headers['X-QX-Event']).toBe('payment.failed');
});
});

// -------------------------------------------------------------------------
describe('bulkRevoke', () => {
it('revokes all keys on full success', async () => {
Expand Down Expand Up @@ -335,4 +385,3 @@ describe('DeveloperService', () => {
});
});
});

49 changes: 49 additions & 0 deletions app/backend/src/developer/dto/developer.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,48 @@ import {
IsUUID,
ArrayMinSize,
ArrayMaxSize,
IsBoolean,
IsEnum,
IsISO8601,
IsOptional,
} from 'class-validator';

export const WEBHOOK_SAMPLE_EVENT_TYPES = [
'link.created',
'payment.received',
'payment.settled',
'payment.failed',
] as const;

export type WebhookSampleEventType = (typeof WEBHOOK_SAMPLE_EVENT_TYPES)[number];

export class WebhookSampleEventDto {
@ApiPropertyOptional({
enum: WEBHOOK_SAMPLE_EVENT_TYPES,
default: 'payment.received',
description: 'Canonical event type to send to the webhook receiver.',
})
@IsEnum(WEBHOOK_SAMPLE_EVENT_TYPES)
@IsOptional()
event_type?: WebhookSampleEventType;

@ApiPropertyOptional({
default: true,
description: 'When false, omits QuickEx signature headers for receivers that are testing unsigned payloads.',
})
@IsBoolean()
@IsOptional()
include_signature?: boolean;

@ApiPropertyOptional({
example: '2026-04-29T12:00:00.000Z',
description: 'Optional event timestamp. Defaults to the current server time.',
})
@IsISO8601()
@IsOptional()
timestamp?: string;
}

export class BulkRevokeDto {
@ApiProperty({
description: 'Array of API key UUIDs to revoke (max 100)',
Expand Down Expand Up @@ -65,6 +105,15 @@ export class WebhookTestResultDto {

@ApiProperty({ example: '2026-04-29T12:00:00.000Z' })
sent_at: string;

@ApiPropertyOptional({ enum: WEBHOOK_SAMPLE_EVENT_TYPES, example: 'payment.received' })
event_type?: WebhookSampleEventType;

@ApiPropertyOptional({ example: 'evt_sample_123' })
event_id?: string;

@ApiPropertyOptional({ example: true })
signature_included?: boolean;
}

export class HealthComponentsDto {
Expand Down
Loading
Loading