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_upis an interactive control completed now (3DS, SCA). The customer stays in the flow.blockis final. Nothing resumes.holdis 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:
{
"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#
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"
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:
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_mshas long elapsed), executes it through your own payment stack, and reports the outcome as usual. - Reject is terminal. PayInference records outcome
blockedwith reason codeHOLD_REVIEW_REJECTEDagainst 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):
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:
{
"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
blockedagainst the held decision so the decision log shows the payment was stopped by review, not lost. (A rejection in the built-in queue records thisblockedoutcome for you.) - If you re-decided after release, the held decision gets
blockedonly when rejected; otherwise it simply has no outcome and the fresh decision carries the result. Both are visible under the sameorder_idin 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_msis 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.