PayinferenceDocs
payinference.com

Hold and review integration

hold means: pause the payment in a pending state until a review, approval, or policy condition completes. PayInference decides that the payment must wait; your systems own the pending state, the review, and the release.

hold differs from the two instructions it is often confused with:

  • step_up is an interactive control completed now (3DS, SCA). The customer stays in the flow.
  • block is final. Nothing resumes.
  • hold is asynchronous. The customer or agent is told the payment is pending, and execution happens later, or never.

When hold is returned#

A policy rule with a hold action matched (reason code HOLD_REQUIRED_BY_POLICY). Conditions can combine amount, region, method, customer type, and the risk_score you supply:

json
{
  "rule_id": "hold_large_first_purchase",
  "name": "Hold large first purchases for review",
  "priority": 10,
  "enabled": true,
  "when": { "amount_gte": 100000, "customer_type_in": ["new", "guest"] },
  "then": { "type": "hold" }
}

Hold outranks step_up when both rules match, and never overrides block.

The flow#

text
Decision: hold
   │
   ├─ 1. Park the order in your pending state; tell the customer it is under review
   ├─ 2. Queue the review (human approval, compliance check, agent supervisor, …)
   │
   ├─ 3a. Released → execute on route.primary_provider
   │        (request a fresh decision first if ttl_ms has elapsed, which it
   │         usually has; pass the same order_id)
   │
   └─ 3b. Rejected → stop; report the outcome as "blocked"
ts
const decision = await payinference.decide(params);

if (decision.action === 'hold') {
  await orders.markPendingReview(order.id, {
    decision_id: decision.decision_id,
    planned_provider: decision.route?.primary_provider ?? null,
  });
  await reviewQueue.enqueue(order.id);
  return { status: 'pending_review' };
}

And when the review resolves:

ts
async function onReviewResolved(orderId: string, approved: boolean) {
  const order = await orders.get(orderId);

  if (!approved) {
    await payinference.recordOutcome({
      decision_id: order.decision_id,
      order_id: orderId,
      provider_used: order.planned_provider ?? 'stripe',
      outcome: 'blocked',
      provider_latency_ms: 0,
      amount: order.amount,
      currency: order.currency,
    });
    return orders.reject(orderId);
  }

  // Holds resolve minutes or hours later; the original decision's ttl_ms has
  // elapsed, so ask again with the same order_id and execute the fresh answer.
  const fresh = await payinference.decide({
    order_id: orderId,
    transaction: order.transactionContext,
    available_providers: order.availableProviders,
  });
  if (fresh.action === 'block') return orders.reject(orderId);
  return chargeWithProvider(fresh.route!.primary_provider, order);
}

Built-in review queue#

If you don't have your own review tooling, the dashboard ships one: Product → Hold Review lists every held decision that has neither an outcome nor a review, with the context a reviewer needs (amount, risk band and score, reason codes, matched rules, the planned provider) and Approve / Reject actions. Resolving is role-gated to owner, admin, and analyst; every resolution is audit-logged (decision.hold_approved / decision.hold_rejected).

The semantics are exactly the flow above:

  • Approve marks the review resolved — nothing executes. Your backend picks the resolution up, requests a fresh decision (the original ttl_ms has long elapsed), executes it through your own payment stack, and reports the outcome as usual.
  • Reject is terminal. PayInference records outcome blocked with reason code HOLD_REVIEW_REJECTED against the held decision for you — do not report a second outcome for it.

Polling for resolutions#

Merchant backends learn about resolutions by polling the decision log with their API key (webhooks for review resolutions are not available yet):

text
GET /v1/decisions?action=hold&unresolved=true     ← still waiting for review
GET /v1/decisions?action=hold                     ← all holds; resolved ones carry `review`

Each decision in the response carries a review field once resolved:

json
{
  "decision_id": "dec_01…",
  "action": "hold",
  "review": {
    "resolution": "approved",
    "reason_code": "HOLD_REVIEW_APPROVED",
    "reviewed_by": "ops@example.com",
    "note": null,
    "reviewed_at": "2026-07-12T10:00:00.000Z"
  },
  "outcome": null
}

resolution: "approved" with outcome: null means: released — execute now (fresh decision, same order_id) and report the outcome. resolution: "rejected" arrives together with the recorded blocked outcome.

Reviews can also be resolved programmatically from your own back office via POST /v1/decisions/{decision_id}/review with { "resolution": "approve" } or { "resolution": "reject", "note": "…" } — dashboard-session auth only (owner/admin/analyst); API keys cannot resolve reviews. A decision can be reviewed once; a hold whose outcome was already reported cannot be reviewed.

Reporting outcomes for held payments#

One decision, one outcome, as always:

  • Released and executed: report the provider result (approved, soft_declined, and so on) against the decision you executed.
  • Rejected in review: report blocked against the held decision so the decision log shows the payment was stopped by review, not lost. (A rejection in the built-in queue records this blocked outcome for you.)
  • If you re-decided after release, the held decision gets blocked only when rejected; otherwise it simply has no outcome and the fresh decision carries the result. Both are visible under the same order_id in the decision log.

Guardrails#

  • Bound the pending state. Give every hold a review SLA and an expiry; an order stuck in review for days should resolve to rejected, not linger.
  • Do not execute a stale decision. ttl_ms is 3 seconds; a released hold virtually always needs a fresh decision. The fresh call is cheap and keeps provider health current.
  • Keep the customer informed. A hold is a product experience, not just an internal state; say "under review" rather than failing silently.
  • Audit. Log decision_id, reviewer, and resolution with the order. The decision log holds the PayInference side; your review trail holds the human side.