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 API
🚧

Status 2 (Compliance Decline) and 6 (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:

  1. Notify the operator / support team with the cardholderId and status.
  2. Check if re-submission is appropriate — see Handling declines for the diagnosis checklist.
  3. If re-submitting: call POST /cardholders/{cardholderId}/kyc-url/refresh, re-deliver the new link to the cardholder, and restart polling.
  4. 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.`);
}

What's next