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.
| Action | Endpoint | Use it when |
|---|---|---|
| Recreate | POST /api/v1/merchants/me/orders/{orderId}/payments/{paymentId}/recreate | The 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 |
| Extend | POST …/payments/{paymentId}/extend | The attempt is still open and the customer needs more time. Adds 24 hours |
| Fulfill from a transaction id | POST …/payments/{paymentId}/fulfill-sar | You 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
403withcode: "ROLE_NOT_ALLOWED". - Live only. A sandbox key cannot use the actions (
403withcode: "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
503withcode: "FEATURE_DISABLED"; polls and replays of an existingidempotencyKeykeep working. Extensions can be switched off independently, so extend can return503while 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
429carries 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 endsBLOCKEDwithAPPROVAL_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.
| Requirement | reason when unmet |
|---|---|
| Fiat rail that is currently enabled | RAIL_NOT_SUPPORTED |
Payment EXPIRED, CANCELLED, or FAILED | PAYMENT_STATUS_NOT_ELIGIBLE |
Order CREATED or PARTIALLY_FULFILLED | ORDER_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.
| Requirement | reason when unmet |
|---|---|
| Fiat rail that is currently enabled | RAIL_NOT_SUPPORTED |
Payment CREATED | PAYMENT_STATUS_NOT_ELIGIBLE |
Attempt is live on-chain (railIdentifier set) | INTENT_HASH_MISSING |
quoteExpiresAt is at most 6 hours away | EXTEND_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.
| Requirement | reason when unmet |
|---|---|
| Fiat rail that is currently enabled | RAIL_NOT_SUPPORTED |
| Automated release available in this environment | SAR_DISABLED |
| Not PayPal (PayPal release needs memo and name evidence an id cannot carry) | PAYPAL_NOT_SUPPORTED |
| Rail supports automated release | SAR_RAIL_DISABLED |
| Venmo, Cash App, or Wise, and the recipient offers automated release | SAR_NOT_AVAILABLE |
Payment CREATED | PAYMENT_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.
| Situation | Response |
|---|---|
Same key, same order, payment, action, and txId | 200 with the existing action's id and current status, in any state, even after the feature is switched off |
| Same key, anything else different | 422 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 attempt | 409 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.
| Status | code | Cause |
|---|---|---|
400 | A path parameter or the body failed validation. Detail in responseObject.fieldErrors | |
401 | Missing or invalid API key, or an invalid dashboard session | |
403 | ROLE_NOT_ALLOWED | Dashboard session without the Owner or Manager role |
403 | SANDBOX_NOT_SUPPORTED | Sandbox key |
403 | ACTION_DENIED | This action is not allowed on your plan. Contact support |
404 | The order is not yours, or the payment is not on it | |
409 | ACTION_IN_PROGRESS | A live action of the same kind already exists; actionId is its id |
422 | PAYMENT_NOT_ELIGIBLE | reason is one of the reason codes listed under the action |
422 | IDEMPOTENCY_KEY_MISMATCH | The key was already used for a different request |
429 | Per-merchant action limit or global limit exceeded | |
502 | SUPPORT_UNAVAILABLE | Peer 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 |
503 | FEATURE_DISABLED | Actions 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;
};
| Status | Meaning |
|---|---|
PENDING_APPROVAL | Waiting for approval by Peer. Ends within 15 minutes either way |
QUEUED | Accepted, not started |
RUNNING | In progress. A fulfil can stay here up to 30 minutes while its transaction is pending |
SUCCEEDED | Done. result is set |
BLOCKED | Never ran: the action was rejected (APPROVAL_REJECTED), not approved in time (APPROVAL_EXPIRED), or cancelled by Peer (ACTION_CANCELED) |
FAILED | Ran 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.
| Code | Actions | Meaning |
|---|---|---|
NEEDS_MANUAL_REVIEW | all | The action needs manual review. Peer support will follow up |
APPROVAL_REJECTED, APPROVAL_EXPIRED, ACTION_CANCELED | all | See BLOCKED above |
ACTION_EXPIRED | all | The action was not picked up in time |
FULFILL_FAILED | fulfil | The settlement transaction failed on-chain. A new request retries |
FULFILL_TIMEOUT | fulfil | The settlement transaction was still pending at the deadline. Check the payment before retrying |
INTENT_ALREADY_EXPIRED, PAYMENT_NOT_OPEN | extend | The 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_EXCEEDED | extend | The attempt has reached its 5-day lifetime |
UNSUPPORTED_INTENT_GUARDIAN | extend | This attempt cannot be extended |
INSUFFICIENT_EXTENSION_TOKEN_BALANCE | extend | Extensions are temporarily unavailable on Peer's side. Retry later |
INTENT_EXTENSION_TRANSACTION_REVERTED | extend | The extension transaction reverted. A new request retries |
INTENT_NOT_LIVE | recreate | The reopened attempt was no longer live when checked |
DISPATCH_FAILED | recreate | The reopened attempt could not be started on-chain. A new request retries |
MERCHANT_WALLET_NOT_DELEGATED, MERCHANT_SIGNING_UNAVAILABLE | recreate | Your 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_FAILED | all | Anything else. Contact support with the action id |
Errors
| Status | Cause |
|---|---|
401 | Missing or invalid API key |
404 | The action does not exist, or is not on a payment of this order for your merchant |
429 | Per-merchant read limit or global limit exceeded |
502 | SUPPORT_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.