Created
June 12, 2026 09:53
-
-
Save stanleyugwu/94afea215cfd7d47a4e8018b94cce2fa to your computer and use it in GitHub Desktop.
Webhook
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Partna v4 webhook handler. Follows Partna's best practices: | |
| * 1. Verify the RSA-PSS signature before doing anything (reject spoofed events). | |
| * 2. Acknowledge with 200 immediately — a slow handler triggers redelivery. | |
| * 3. Process asynchronously and idempotently (a status change is applied at most once). | |
| * 4. Log the raw payload for troubleshooting. | |
| * See {@link verifyPartnaWebhookSignature} and {@link processPartnaWebhookEvent}. | |
| */ | |
| handlePartnaWebhook = async (req: Request<any, any, PartnaWebhookPayload>, res: Response) => { | |
| const payload = (req.body ?? {}) as PartnaWebhookPayload; | |
| const { data, signature } = payload; | |
| const requestId = randomUUID(); | |
| // Never process (or even log as trusted) an unverified event. | |
| if (!data || !verifyPartnaWebhookSignature(data, signature)) { | |
| logger.warn('[Ramp-Webhook] Partna webhook signature verification failed', { | |
| requestId, | |
| event: payload.event, | |
| orderId: data?.transactionReference, | |
| payload, | |
| }); | |
| const resBody: ErrorResponse = { | |
| code: ErrorCodes.INVALID_PARAMETERS, | |
| message: 'Invalid webhook signature', | |
| success: false, | |
| requestId, | |
| }; | |
| res.status(StatusCodes.UNAUTHORIZED).json(resBody); | |
| return; | |
| } | |
| // Log the raw (verified) payload for troubleshooting. | |
| logger.info('[Ramp-Webhook] Partna webhook received', { | |
| requestId, | |
| event: payload.event, | |
| orderId: data.transactionReference, | |
| payload, | |
| }); | |
| // Acknowledge immediately, before processing, so we never trip Partna's retry timeout. | |
| res.status(StatusCodes.OK).json({ received: true, requestId }); | |
| // Process after the ack. Fire-and-forget; idempotent so duplicate deliveries are safe. | |
| void this.processPartnaWebhookEvent(payload, requestId).catch((err) => | |
| logger.error('[Ramp-Webhook] Partna webhook processing failed', { | |
| requestId, | |
| event: payload.event, | |
| orderId: data.transactionReference, | |
| err, | |
| }), | |
| ); | |
| }; | |
| /** | |
| * Applies a verified Partna webhook to its transaction. Runs after the 200 ack, so it must | |
| * not touch `res`. Idempotent: a no-op when the status is already current, so redelivered | |
| * events don't re-send the receipt email or re-broadcast. `requestId` correlates these logs | |
| * with the (already-sent) ack from {@link handlePartnaWebhook}. | |
| */ | |
| private async processPartnaWebhookEvent(payload: PartnaWebhookPayload, requestId: string): Promise<void> { | |
| const { event, data } = payload; | |
| // Only ramp order lifecycle events touch a transaction; ignore the rest for now. | |
| if (event !== 'Onramp' && event !== 'Offramp') return; | |
| // `transactionReference` is the rampReference we submit at creation (== our orderId). | |
| const orderId = data.transactionReference; | |
| if (!orderId) { | |
| logger.warn('[Ramp-Webhook] Partna webhook missing transactionReference', { requestId, event }); | |
| return; | |
| } | |
| // We don't allow thrown repo error to bubble to global error handler from inside | |
| // a webhook. We just log the error, we don't need to send a response. | |
| let transaction: P2pPaymentTransactionResultType; | |
| try { | |
| transaction = await this.rampRepository.findByOrderIdOrThrow(orderId); | |
| } catch (error) { | |
| // Orders are persisted before the webhook can arrive, so this is unexpected. | |
| logger.warn('[Ramp-Webhook] Partna webhook: transaction not found', { requestId, event, orderId }); | |
| return; | |
| } | |
| const nextStatus = mapProviderStatus(data.status); | |
| // Unknown status (already warned) → leave unchanged. Idempotency: skip when unchanged so | |
| // redelivery is a no-op (and an unknown status never downgrades a settled order). | |
| if (!nextStatus || transaction.status === nextStatus) { | |
| logger.info('[Ramp-Webhook] Partna webhook skipped (status unchanged)', { | |
| requestId, | |
| event, | |
| orderId, | |
| status: transaction.status, | |
| }); | |
| return; | |
| } | |
| await this.rampRepository.updateTransactionStatus(orderId, nextStatus, data.transactionHash); | |
| logger.info('[Ramp-Webhook] Partna webhook processed', { | |
| requestId, | |
| event, | |
| orderId, | |
| from: transaction.status, | |
| to: nextStatus, | |
| }); | |
| if (nextStatus === P2pPaymentTransactionStatus.COMPLETED) { | |
| this.rampService | |
| .sendTransactionReceiptEmail(transaction) | |
| .catch((err) => logger.error('[Ramp-Webhook] Webhook receipt email failed', { requestId, orderId, err })); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment