Poll Compliance Status
The compliance review step is the only part of cardholder onboarding that can't be completed in a single synchronous call — it requires polling GET /cardholders/{cardholderId} until a terminal status is reached. This recipe covers a production-grade polling implementation with proper backoff, a complete status-to-action state machine, KYC URL refresh logic, and the separation of polling from your main request thread.
The compliance status state machine
Before writing any polling logic, it helps to map every possible status to the action it requires:
const STATUS = {
0: 'PENDING', // Transitional — keep polling
1: 'APPROVED', // Terminal ✅ — issue cards
2: 'COMPLIANCE_DECLINE', // Terminal ⚠️ — may retry or raise ticket
3: 'UNDER_REVIEW', // Active — keep polling; refresh URL if expired
4: 'DRAFT', // Not submitted yet — unexpected here
5: 'DELETED', // Terminal ❌ — cardholder removed
6: 'ERROR', // Terminal ⚠️ — may retry or raise ticket
7: 'ADMIN_DECLINE', // Terminal ❌ — raise support ticket
};
const TERMINAL = new Set([1, 2, 5, 6, 7]);
const RETRYABLE = new Set([2, 6]); // may be recoverable — probabilistic
const ESCALATE = new Set([5, 7]); // not recoverable via APIStatus
2(Compliance Decline) and6(Error) are shown as "retryable" in that a re-submission is sometimes possible — but this is probabilistic, not guaranteed. Always inspect the reason before assuming a retry will succeed. If re-submission fails or the reason indicates a locked-field mismatch, raise a support ticket. See Handling declines.
Production polling pattern
In production, don't use an in-process sleep loop (as in the Zero-to-Card recipe). Instead, use a persistent queue or scheduled job — so polling survives deployments and process restarts, and failed polls are retried automatically.
The sketch below uses a generic queue object to illustrate the pattern; replace with your own job queue (BullMQ, SQS, Temporal, Inngest, etc.):
// compliance-poller.js
const { api } = require('./client');
// Backoff schedule: delay in seconds before each poll attempt
// Designed to be dense early (fast feedback for automated checks)
// and sparse later (manual reviews can take hours)
const BACKOFF_SCHEDULE_S = [
15, 30, 30, 60, // 0–2 min: rapid initial checks
120, 120, 120, // 2–8 min: every 2 min
300, 300, 300, 300, // 8–28 min: every 5 min
600, 600, 600, 600, 600, // 28–78 min: every 10 min
1800, 1800, 1800, 1800, // 78 min – 8 h: every 30 min
3600, 3600, 3600, // 8–11 h: every hour
7200, 7200, 7200, 7200, // 11–19 h: every 2 hours
14400, 14400, // 19–27 h: every 4 hours
];
const MAX_ELAPSED_H = 28; // Give up after 28 hours
async function pollComplianceStatus({
cardholderId,
attemptIndex = 0,
startedAt = Date.now(),
onApproved, // callback(cardholderId) when approved
onDeclined, // callback(cardholderId, status, reason) on terminal decline
onError, // callback(cardholderId, error) on unexpected error
}) {
// Check if we've exceeded the max elapsed time
const elapsedH = (Date.now() - startedAt) / 3_600_000;
if (elapsedH > MAX_ELAPSED_H) {
console.error(`[${cardholderId}] Timeout after ${MAX_ELAPSED_H}h — escalating.`);
await onDeclined?.(cardholderId, -1, 'POLL_TIMEOUT');
return;
}
let cardholder;
try {
cardholder = await api('GET', `/cardholders/${cardholderId}`);
} catch (err) {
console.error(`[${cardholderId}] Poll failed: ${err.message}. Will retry.`);
await scheduleNextPoll({ cardholderId, attemptIndex, startedAt,
onApproved, onDeclined, onError });
return;
}
const { status, applicationLink } = cardholder;
console.log(`[${cardholderId}] status=${status} (${STATUS[status] ?? 'UNKNOWN'})`);
// ── Terminal: success ────────────────────────────────────────────
if (status === 1) {
await onApproved?.(cardholderId);
return;
}
// ── Terminal: escalation needed ──────────────────────────────────
if (ESCALATE.has(status)) {
console.error(`[${cardholderId}] Terminal status ${status} — raise support ticket.`);
await onDeclined?.(cardholderId, status, STATUS[status]);
return;
}
// ── Potentially retryable decline ────────────────────────────────
if (RETRYABLE.has(status)) {
// Don't automatically retry — notify and let the operator decide
console.warn(`[${cardholderId}] Status ${status} — decline/error, may be retryable.`);
await onDeclined?.(cardholderId, status, STATUS[status]);
return;
}
// ── Still in review — check if KYC URL needs refreshing ──────────
if (status === 3) {
// If applicationLink is missing or known-expired, refresh it
// (implement your own expiry-tracking logic as needed)
if (!applicationLink) {
try {
const { applicationLink: newLink } = await api(
'POST', `/cardholders/${cardholderId}/kyc-url/refresh`
);
console.log(`[${cardholderId}] KYC URL refreshed: ${newLink}`);
// Re-deliver newLink to cardholder via your notification system
} catch (refreshErr) {
console.warn(`[${cardholderId}] KYC URL refresh failed: ${refreshErr.message}`);
}
}
}
// ── Schedule next poll ────────────────────────────────────────────
await scheduleNextPoll({
cardholderId, attemptIndex, startedAt,
onApproved, onDeclined, onError,
});
}
async function scheduleNextPoll({ cardholderId, attemptIndex, startedAt,
onApproved, onDeclined, onError }) {
const delayS = BACKOFF_SCHEDULE_S[
Math.min(attemptIndex, BACKOFF_SCHEDULE_S.length - 1)
];
// In a real implementation, enqueue a job:
// await queue.add('pollCompliance', { cardholderId,
// attemptIndex: attemptIndex + 1, startedAt }, { delay: delayS * 1000 });
console.log(`[${cardholderId}] Next poll in ${delayS}s (attempt ${attemptIndex + 1})`);
}Handling retryable declines
When status = 2 (Compliance Decline) or status = 6 (Error), the onDeclined callback fires. Your application should:
- Notify the operator / support team with the
cardholderIdand status. - Check if re-submission is appropriate — see Handling declines for the diagnosis checklist.
- If re-submitting: call
POST /cardholders/{cardholderId}/kyc-url/refresh, re-deliver the new link to the cardholder, and restart polling. - If not re-submitting (locked-field mismatch, repeated declines, admin decline): raise a support ticket via
POST /support/tickets.
async function handleDecline(cardholderId, status, reason) {
if (reason === 'COMPLIANCE_DECLINED' || reason === 'COMPLIANCE_ERROR') {
// Attempt to regenerate KYC URL and notify cardholder for retry
try {
const { applicationLink } = await api(
'POST', `/cardholders/${cardholderId}/kyc-url/refresh`
);
// Notify cardholder with new link (your notification system)
await notifyCardholder(cardholderId, applicationLink);
// Restart polling
await pollComplianceStatus({ cardholderId, onApproved, onDeclined, onError });
} catch {
// Refresh failed — escalate to support
await raiseSupportTicket(cardholderId, status);
}
} else {
// Admin decline or other terminal status — raise ticket immediately
await raiseSupportTicket(cardholderId, status);
}
}
async function raiseSupportTicket(cardholderId, status) {
await api('POST', '/support/tickets', {
programId: parseInt(process.env.AXYS_PROGRAM_ID),
cardholderId,
subject: `Compliance status ${status} — action required`,
message: `Cardholder ${cardholderId} reached status ${status}. ` +
`Automated re-submission not applicable. Manual review requested.`,
priority: 3,
});
console.log(`[${cardholderId}] Support ticket raised.`);
}