Skip to main content

Remediation

Most stuck payments resolve inside the checkout: the customer verifies through the Peer app or extension, or opens support from the page. When you would rather act from your own backend, three actions are available per payment attempt. Each is asynchronous: the request returns 202 with an action id, Peer runs the action, and you poll until it reaches a terminal status.

ActionEndpointUse it when
RecreatePOST /api/v1/merchants/me/orders/{orderId}/payments/{paymentId}/recreateThe attempt expired, was cancelled, or failed, and the customer paid (or will pay) the same recipient anyway. Reopens the attempt against the same recipient so their payment can still be verified
ExtendPOST …/payments/{paymentId}/extendThe attempt is still open and the customer needs more time. Adds 24 hours
Fulfill from a transaction idPOST …/payments/{paymentId}/fulfill-sarYou hold the customer's payment transaction id and the recipient supports automated release. The payment settles without the customer verifying

Poll any of them with GET …/actions/{actionId}. Find the paymentId with list payments for an order.

Before you start

Action failures carry a machine-readable code in responseObject.code of the standard envelope. Validation failures carry fieldErrors there instead. The full table is under action errors.

  • Authentication. Your API key, or a dashboard session with the Owner or Manager role. Cashier sessions get 403 with code: "ROLE_NOT_ALLOWED".
  • Live only. A sandbox key cannot use the actions (403 with code: "SANDBOX_NOT_SUPPORTED"). Sandbox payments settle by simulation and need no remediation.
  • Availability. The actions can be switched off. While off, a new action returns 503 with code: "FEATURE_DISABLED"; polls and replays of an existing idempotencyKey keep working. Extensions can be switched off independently, so extend can return 503 while recreate and fulfil work.
  • Rate limits. 30 action requests and 120 reads (polls and per-order payment lists) per minute per merchant, on top of the global limit. A 429 carries the same headers.
  • Fiat rails only. Crypto rails, and rails that are currently disabled, are rejected with reason: "RAIL_NOT_SUPPORTED".
  • Approval. Depending on your plan, an action may first wait for approval by Peer (PENDING_APPROVAL). The approval window is 15 minutes; an action nobody approves ends BLOCKED with APPROVAL_EXPIRED.

Recreate

POST /api/v1/merchants/me/orders/{orderId}/payments/{paymentId}/recreate
X-API-Key: <your key>
Content-Type: application/json

{ "idempotencyKey": "order-42-recreate-1" }

Reopens the closed attempt with the same recipient: the payment returns to CREATED with a fresh window. Use it when the customer sent the money after the window closed, or is about to, so their payment to the same recipient can still be verified.

Requirementreason when unmet
Fiat rail that is currently enabledRAIL_NOT_SUPPORTED
Payment EXPIRED, CANCELLED, or FAILEDPAYMENT_STATUS_NOT_ELIGIBLE
Order CREATED or PARTIALLY_FULFILLEDORDER_STATUS_NOT_ELIGIBLE

The body is optional; idempotencyKey is described under idempotency.

On success result.intentHash is the reopened attempt's new on-chain identifier, and the same payment row now shows status: "CREATED", railIdentifier equal to that value, and the new quoteExpiresAt. No webhook fires for the reopening; PAYMENT_SETTLED fires if the attempt later settles, as for a first attempt.

Extend

POST /api/v1/merchants/me/orders/{orderId}/payments/{paymentId}/extend
X-API-Key: <your key>
Content-Type: application/json

{ "idempotencyKey": "order-42-extend-1" }

Adds 24 hours to the attempt's window. The amount is fixed; call again for another 24 hours once the new expiry is within the 6-hour lead.

Requirementreason when unmet
Fiat rail that is currently enabledRAIL_NOT_SUPPORTED
Payment CREATEDPAYMENT_STATUS_NOT_ELIGIBLE
Attempt is live on-chain (railIdentifier set)INTENT_HASH_MISSING
quoteExpiresAt is at most 6 hours awayEXTEND_TOO_EARLY

An attempt can be extended to at most 5 days from its creation. A request that would pass that point is accepted and then fails on the poll with FIVE_DAY_LIFETIME_EXCEEDED.

On success result.confirmedExpiryTime is the new expiry (ISO 8601). The payment row's quoteExpiresAt follows within a few minutes; read the result, not the payment, straight after success.

Fulfill from a transaction id

POST /api/v1/merchants/me/orders/{orderId}/payments/{paymentId}/fulfill-sar
X-API-Key: <your key>
Content-Type: application/json

{ "txId": "4123456789012345678", "idempotencyKey": "order-42-fulfill-1" }

Settles the attempt from the customer's payment transaction id, without the customer verifying in the Peer app or extension. txId is the transaction or payment id shown on the customer's receipt in their payment app, 1–256 characters. The attempt must have been created with Seller Automated Release available, which depends on the recipient and on your plan, and the rail must be Venmo, Cash App, or Wise.

Requirementreason when unmet
Fiat rail that is currently enabledRAIL_NOT_SUPPORTED
Automated release available in this environmentSAR_DISABLED
Not PayPal (PayPal release needs memo and name evidence an id cannot carry)PAYPAL_NOT_SUPPORTED
Rail supports automated releaseSAR_RAIL_DISABLED
Venmo, Cash App, or Wise, and the recipient offers automated releaseSAR_NOT_AVAILABLE
Payment CREATEDPAYMENT_STATUS_NOT_ELIGIBLE
Attempt is live on-chain (railIdentifier set)INTENT_HASH_MISSING

On success result is { dispatchAttemptId, fulfillTxHash }, the payment reaches SETTLED, and PAYMENT_SETTLED fires (then ORDER_FULFILLED once the order is covered). If the payment had already settled by the time the action ran, result is { alreadyCompleted: true, fulfillTxHash } and the action still reports SUCCEEDED. A wrong or unverifiable id ends the action FAILED with REMEDIATION_FAILED; check the id against the customer's receipt and send a new request with a new key, or contact Peer support with the action id.

Idempotency and conflicts

idempotencyKey (8–200 characters, optional on every action) makes a request safe to retry. Keys are scoped to your merchant, so any scheme that is unique per action attempt on your side works.

SituationResponse
Same key, same order, payment, action, and txId200 with the existing action's id and current status, in any state, even after the feature is switched off
Same key, anything else different422 with code: "IDEMPOTENCY_KEY_MISMATCH"
No key (or an unused one) while an action of the same kind is still live (waiting for approval, queued, or running) on the same attempt409 with code: "ACTION_IN_PROGRESS" and actionId of the live action, which you can poll

Keys are optional by design: the payments list is the source of truth for outcomes, and a 409 hands you the live action id. Reuse a key only to retry the same request after a timeout or a 502. After an action ends FAILED or BLOCKED, send a new key (or none): the old key keeps replaying the finished action.

Action errors

The three actions share one error table. The code is in responseObject.code; a 422 eligibility failure adds responseObject.reason, and a 409 adds responseObject.actionId.

StatuscodeCause
400A path parameter or the body failed validation. Detail in responseObject.fieldErrors
401Missing or invalid API key, or an invalid dashboard session
403ROLE_NOT_ALLOWEDDashboard session without the Owner or Manager role
403SANDBOX_NOT_SUPPORTEDSandbox key
403ACTION_DENIEDThis action is not allowed on your plan. Contact support
404The order is not yours, or the payment is not on it
409ACTION_IN_PROGRESSA live action of the same kind already exists; actionId is its id
422PAYMENT_NOT_ELIGIBLEreason is one of the reason codes listed under the action
422IDEMPOTENCY_KEY_MISMATCHThe key was already used for a different request
429Per-merchant action limit or global limit exceeded
502SUPPORT_UNAVAILABLEPeer did not answer in time. The action may or may not have been recorded: retry the same request with the same idempotencyKey (a recorded action replays as 200), or read the payment before sending a different request
503FEATURE_DISABLEDActions are switched off, or, for extend only, extensions are switched off

Poll an action

GET /api/v1/merchants/me/orders/{orderId}/actions/{actionId}
X-API-Key: <your key>

Response

type MerchantAction = {
actionId: string;
actionName: 'RECREATE' | 'EXTEND' | 'FULFILL_SAR';
paymentId: string;
status: 'PENDING_APPROVAL' | 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'BLOCKED' | 'FAILED';
error: { code: string; message: string } | null;
result: Record<string, unknown> | null;
payment: CheckoutPayment;
createdAt: string;
startedAt: string | null;
finishedAt: string | null;
};
StatusMeaning
PENDING_APPROVALWaiting for approval by Peer. Ends within 15 minutes either way
QUEUEDAccepted, not started
RUNNINGIn progress. A fulfil can stay here up to 30 minutes while its transaction is pending
SUCCEEDEDDone. result is set
BLOCKEDNever ran: the action was rejected (APPROVAL_REJECTED), not approved in time (APPROVAL_EXPIRED), or cancelled by Peer (ACTION_CANCELED)
FAILEDRan and did not complete, or expired before running (ACTION_EXPIRED). error says why

Terminal statuses are stable. A later recreate or extend on the same attempt, or a late webhook about the same transaction, never changes an earlier action's status. payment is always the attempt's current row, so after a successful recreate it already shows the reopened attempt (CREATED, new railIdentifier, new quoteExpiresAt).

Error codes

error.code is one of the values below; anything else is reported as REMEDIATION_FAILED with the action id to quote to support.

CodeActionsMeaning
NEEDS_MANUAL_REVIEWallThe action needs manual review. Peer support will follow up
APPROVAL_REJECTED, APPROVAL_EXPIRED, ACTION_CANCELEDallSee BLOCKED above
ACTION_EXPIREDallThe action was not picked up in time
FULFILL_FAILEDfulfilThe settlement transaction failed on-chain. A new request retries
FULFILL_TIMEOUTfulfilThe settlement transaction was still pending at the deadline. Check the payment before retrying
INTENT_ALREADY_EXPIRED, PAYMENT_NOT_OPENextendThe attempt was no longer open when the extension ran: it expired, or it settled in the meantime. Reread the payment; recreate only if it closed unpaid
FIVE_DAY_LIFETIME_EXCEEDEDextendThe attempt has reached its 5-day lifetime
UNSUPPORTED_INTENT_GUARDIANextendThis attempt cannot be extended
INSUFFICIENT_EXTENSION_TOKEN_BALANCEextendExtensions are temporarily unavailable on Peer's side. Retry later
INTENT_EXTENSION_TRANSACTION_REVERTEDextendThe extension transaction reverted. A new request retries
INTENT_NOT_LIVErecreateThe reopened attempt was no longer live when checked
DISPATCH_FAILEDrecreateThe reopened attempt could not be started on-chain. A new request retries
MERCHANT_WALLET_NOT_DELEGATED, MERCHANT_SIGNING_UNAVAILABLErecreateYour account signs its own orders and its wallet is not ready. Re-delegate the merchant wallet in the dashboard, or contact Peer support with the action id
REMEDIATION_FAILEDallAnything else. Contact support with the action id

Errors

StatusCause
401Missing or invalid API key
404The action does not exist, or is not on a payment of this order for your merchant
429Per-merchant read limit or global limit exceeded
502SUPPORT_UNAVAILABLE: Peer was unreachable. Retry with backoff

Example: settle from a transaction id and wait

type Envelope<T> = { success: boolean; message: string; responseObject: T | null; statusCode: number };
type Accepted = { actionId: string; status: string };

const BASE = 'https://api.pay.peer.xyz/api/v1/merchants/me/orders';
const headers = { 'X-API-Key': process.env.ZKPAY_API_KEY!, 'content-type': 'application/json' };

// `attemptKey` (8–200 characters) names one logical attempt on your side. Reuse it to retry
// a request that timed out; mint a new one for a deliberate retry after FAILED or BLOCKED.
async function settleFromTxId(orderId: string, paymentId: string, txId: string, attemptKey: string): Promise<MerchantAction> {
const started = await fetch(`${BASE}/${orderId}/payments/${paymentId}/fulfill-sar`, {
method: 'POST',
headers,
body: JSON.stringify({ txId, idempotencyKey: attemptKey }),
signal: AbortSignal.timeout(15_000),
});
const accepted = (await started.json()) as Envelope<Accepted>;
if (!accepted.success || accepted.responseObject === null) {
throw new Error(`${accepted.statusCode}: ${accepted.message}`);
}

const deadline = Date.now() + 45 * 60 * 1000;
for (;;) {
const response = await fetch(`${BASE}/${orderId}/actions/${accepted.responseObject.actionId}`, {
headers,
signal: AbortSignal.timeout(15_000),
});
const body = (await response.json()) as Envelope<MerchantAction>;
if (!body.success || body.responseObject === null) {
throw new Error(`${body.statusCode}: ${body.message}`);
}
const action = body.responseObject;
if (action.status === 'SUCCEEDED' || action.status === 'FAILED' || action.status === 'BLOCKED') {
return action;
}
if (Date.now() > deadline) {
throw new Error(`action ${action.actionId} still ${action.status}`);
}
await new Promise((resolve) => setTimeout(resolve, 5_000));
}
}

Poll every few seconds. Approval can take up to 15 minutes and a pending fulfil transaction up to 30, so keep the overall deadline generous and fall back to list payments for an order if you give up.

Notes

  • Peer support can see the actions you take on an order, so they have the context if the customer contacts them.
  • A fulfil that is still waiting for approval when you recreate the attempt runs against the reopened attempt, which keeps the same recipient.
  • Actions do not exist on sandbox orders and are not exercised by the sandbox test flow.