Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save stanleyugwu/94afea215cfd7d47a4e8018b94cce2fa to your computer and use it in GitHub Desktop.

Select an option

Save stanleyugwu/94afea215cfd7d47a4e8018b94cce2fa to your computer and use it in GitHub Desktop.
Webhook
/**
* 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