Hooks
Lifecycle hooks for logging, payload enrichment, and analytics
Overview
Hooks let you intercept every collect and payout call at two points in the lifecycle, before the request leaves the SDK and after the server responds. Register them once at initialization; they fire on every matching operation automatically.
Every hook is an object with three fields:
fn— the handler.onError— required. Receives any error thrown or rejected byfn. The SDK runsfninside a safe boundary, so a crash in your hook never breaks the payment flow; it is routed here instead.enabled— optional, defaults totrue. Setfalseto switch a hook off without removing its config.
const nylonpay = createNylonPay({
apiKey: 'npk_...',
apiSecret: 'nps_...',
hooks: {
beforeCollect: {
fn: (input) => {
console.log('Initiating collect:', input.reference);
return input;
},
onError: (err) => logger.error({ hook: 'beforeCollect', err }),
},
afterCollect: {
fn: (result, input) => {
if (result.isOk) {
analytics.track('collect.initiated', { reference: result.value.reference });
}
},
onError: (err) => logger.error({ hook: 'afterCollect', err }),
},
},
});Available Hooks
| Hook | When it fires | Can mutate payload |
|---|---|---|
beforeCollect | Before every collectPayment / collectPaymentAndResolve call | Yes |
afterCollect | After every collect call, success or failure | No |
beforePayout | Before every makePayout / makePayoutAndResolve call | Yes |
afterPayout | After every payout call, success or failure | No |
before* Hooks
beforeCollect and beforePayout receive the full input (with reference already set) and run after SDK validation, before the request is signed and sent.
Return a mutated copy from fn to change what gets sent. Return void or undefined to leave the input unchanged.
Your mutated copy is validated again before the request goes out, so a hook cannot send values the SDK would reject on its own. Returning an out of range reference or an amount below the minimum raises a validation error, the same as passing those values directly.
hooks: {
// Add metadata to every collect call
beforeCollect: {
fn: (input) => ({
...input,
metadata: {
...input.metadata,
source: 'checkout-v2',
env: process.env.NODE_ENV ?? 'unknown',
},
}),
onError: (err) => logger.error({ hook: 'beforeCollect', err }),
},
}Async hooks are awaited before the transport call proceeds:
hooks: {
beforeCollect: {
fn: async (input) => {
await auditLog.write({ action: 'collect.before', reference: input.reference });
return input;
},
onError: (err) => alerting.notify('audit log write failed', err),
},
}after* Hooks
afterCollect and afterPayout receive a normalized result and the input sent to the server. They fire after the transport call whether it succeeded or failed.
The input is the payload as sent, with the phone number normalized and the reference resolved. It also carries input.raw, holding the original values you passed before normalization and before any before* hook ran. Read input to log what reached the server, and input.raw to log what the caller submitted.
The result type is Result<{ reference: string; status: string }, string>, consistent across both the fire-and-forget (collectPayment) and blocking (collectPaymentAndResolve) variants. The return value of fn is ignored.
hooks: {
afterCollect: {
fn: (result, input) => {
if (result.isOk) {
logger.info({
event: 'collect.initiated',
reference: result.value.reference,
status: result.value.status,
});
} else {
logger.error({ event: 'collect.failed', error: result.error, amount: input.amount });
}
},
onError: (err) => logger.error({ hook: 'afterCollect', err }),
},
}Use input.raw when your audit trail needs the exact values the caller submitted:
hooks: {
afterCollect: {
fn: (result, input) => {
auditLog.write({
reference: input.reference,
sentPhone: input.customer.phoneNumber, // normalized, e.g. '256700000000'
submittedPhone: input.raw.customer.phoneNumber, // original, e.g. '0700000000'
});
},
onError: (err) => logger.error({ hook: 'afterCollect', err }),
},
}Disabling a hook
Keep the config but switch it off with enabled: false — useful for feature flags:
hooks: {
afterCollect: {
enabled: process.env.ANALYTICS_ENABLED === 'true',
fn: (result) => analytics.track(result),
onError: (err) => logger.warn({ hook: 'afterCollect', err }),
},
}Ordering
For every collect or payout call:
- SDK validates the input (throws on invalid fields)
before*hook runs (awaited)- SDK re-validates the hook's output (throws on invalid fields)
- Transport signs and sends the request
after*hook runs (awaited)- SDK returns the result to the caller
Both hooks apply to the fire-and-forget and the resolve variants. You get one hook pair for both collectPayment and collectPaymentAndResolve.
Error Behavior
A hook fn that throws or rejects never breaks the payment call. The SDK runs it inside a safe boundary and routes the error to that hook's onError. The call then proceeds — for before* hooks, with the original unmutated payload.
This replaces the old "errors propagate to the caller" behavior. Because onError is required, you can't accidentally swallow a failure silently (the worst case for a payments SDK — a "successful" payment whose side-effect actually failed). You decide, explicitly, what happens:
hooks: {
beforeCollect: {
fn: async (input) => {
await criticalAuditSystem.record(input); // if this throws...
return input;
},
// ...the error lands here; the payment still proceeds with the original input
onError: (err) => alerting.pageOncall('audit write failed', err),
},
}onError is itself contained — if your error handler throws, that won't crash the SDK either. But keep it simple and side-effect-light.