{
  "openapi": "3.1.0",
  "info": {
    "title": "P2Flux API",
    "version": "1.0.0",
    "summary": "Non-custodial USDC payments, subscriptions and refunds on Base.",
    "description": "Programmable, non-custodial payments on Base. Money moves **buyer wallet to recipient wallet**, with the P2Flux fee\nsplit out in the same transaction. There is no custody, no balances and no accounts: P2Flux never holds funds and\nnever becomes an intermediary that could.\n\n## Authentication\n\nThere is none, deliberately. A P2Flux request carries no ambient authority - no cookies, no `Authorization` header,\nno credentialed CORS. **The signed capability in the request body is what authorizes the call**, and it is bound to\nan exact recipient, amount and period. Treat every capability as a bearer secret: keep it server-side, encrypted at\nrest, and out of URLs and logs.\n\n## What P2Flux stores about you: nothing\n\nCustomer identity, orders, products, prices, subscription lifecycle and business records stay in **your** database.\nP2Flux handles only the protocol information a payment needs - addresses, amounts, a random reference, a signature.\nThe `reference` is generated by P2Flux and never accepted from a caller, so a shop cannot put an order id or an\nemail address into it by accident. Keep your own `order -> reference` mapping; there is no P2Flux dashboard that\nknows what was bought.\n\n## Amounts\n\nTwo representations, never interchangeable:\n\n- **decimal strings** (`\"10.00\"`) where a human reads the value;\n- **micro-USDC integer strings** (`\"10000000\"`) where arithmetic happens - refund amounts especially.\n\nNever a JSON number. Binary floating point cannot represent most decimal prices exactly, and this is money.\n\n## Reading a result\n\nBranch on the `error` code and the `action`, never on the HTTP status alone. Two results deserve singling out:\n\n- **`WAIT`** (`PAYMENT_CONFIRMING`, `REFUND_CONFIRMING`, `CONFIRMING`) means the transaction exists and the money may\n  already have moved. Poll the same transaction. Starting another one is how a customer pays or gets refunded twice.\n- **`PAYMENT_NOT_FOUND`** from recovery is an as-of-this-block statement, never a permanent verdict.\n\nUnknown request fields are **rejected**, not ignored: a body with a typo'd key is a 400 rather than a silently\ndifferent request. Errors that are worth retrying carry a `retry-after` header.\n\n## Environments\n\n| | API | Hosted checkout | Chain |\n|---|---|---|---|\n| Test | `api-test.p2flux.com` | `pay-test.p2flux.com` | Base Sepolia (84532) |\n| Production | `api.p2flux.com` | `pay.p2flux.com` | Base Mainnet (8453) |\n\nThe two are complete, non-interchangeable stacks: a capability issued by one is refused by the other, by\nconstruction. **Integrate against the test environment** - it is what this specification's server list points at.\nProduction exists and is **pre-launch: not yet open, which is not an outage**. Live state is on the\n[status page](https://p2flux.com/status.html).",
    "contact": {
      "name": "P2Flux",
      "url": "https://p2flux.com/docs/"
    },
    "license": {
      "name": "Documentation for the hosted P2Flux API",
      "url": "https://p2flux.com/terms.html"
    }
  },
  "servers": [
    {
      "url": "https://api-test.p2flux.com",
      "description": "Test - Base Sepolia (84532). The only environment open for integration; value moved here is faucet USDC, not real money."
    }
  ],
  "externalDocs": {
    "description": "Guides, flows and worked examples",
    "url": "https://p2flux.com/docs/"
  },
  "tags": [
    {
      "name": "One-time payments",
      "description": "A single payment to a single recipient."
    },
    {
      "name": "Recurring payments",
      "description": "Signed once by the customer, charged by your renewal job."
    },
    {
      "name": "Refunds",
      "description": "A transfer from the merchant's own wallet back to the wallet that paid."
    },
    {
      "name": "Cancellation",
      "description": "Unsigned calldata for the customer's own wallet to send."
    },
    {
      "name": "Service",
      "description": "Liveness."
    }
  ],
  "paths": {
    "/health": {
      "get": {
        "operationId": "health",
        "summary": "Liveness",
        "tags": [
          "Service"
        ],
        "description": "The one endpoint the whole internet can reach, so it says that the process is up and nothing else - no balances, no counters, no version. Not rate limited. It is not a readiness check: operational state is internal and deliberately not published.",
        "responses": {
          "200": {
            "description": "The process is up.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "const": true
                    }
                  }
                },
                "examples": {
                  "up": {
                    "value": {
                      "ok": true
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/allowances/revoke/prepare": {
      "post": {
        "operationId": "prepareAllowanceRevocation",
        "summary": "Calldata that stops every P2Flux subscription",
        "tags": [
          "Cancellation"
        ],
        "description": "Sets the token allowance to zero - the customer's blunt instrument. It stops every P2Flux subscription for that wallet at once, not just one. Their wallet sends it.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {}
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Unsigned calldata.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "chain_id": {
                      "type": "integer"
                    },
                    "to": {
                      "$ref": "#/components/schemas/Address"
                    },
                    "data": {
                      "type": "string"
                    },
                    "description": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Refused. `error` names which of: `INVALID_REQUEST`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/charges": {
      "post": {
        "operationId": "charge",
        "summary": "Execute one recurring charge",
        "tags": [
          "Recurring payments"
        ],
        "description": "Collect one billing period. Call it from your own renewal job - P2Flux schedules nothing.\n\n**Safe to retry.** The contract allows one charge per period, so a repeat call after a timeout or a crash returns `ALREADY_CHARGED` rather than charging again. Treat that as success.\n\n**`CONFIRMING` is not a failure.** The transaction is on chain and not yet settled: leave the period open, change nothing, and ask again in a few seconds about the same transaction. Its `action` is `WAIT`, and it is the one result that must never be shown to a customer as an error - they have paid.\n\nEvery other outcome carries an `action` telling you what to do; the retry SCHEDULE (an hour, a day, a dunning email) is your business policy, not ours.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "subscription": {
                    "$ref": "#/components/schemas/Token"
                  }
                },
                "required": [
                  "subscription"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The charge outcome.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "enum": [
                        "CHARGED",
                        "ALREADY_CHARGED",
                        "CONFIRMING"
                      ]
                    },
                    "ok": {
                      "type": "boolean",
                      "description": "True for CHARGED and ALREADY_CHARGED - both mean the period is paid."
                    },
                    "already_paid": {
                      "type": "boolean"
                    },
                    "action": {
                      "$ref": "#/components/schemas/MerchantAction"
                    },
                    "tx_hash": {
                      "$ref": "#/components/schemas/Bytes32"
                    },
                    "subscription_id": {
                      "$ref": "#/components/schemas/Bytes32"
                    },
                    "amount": {
                      "$ref": "#/components/schemas/Amount"
                    },
                    "period_index": {
                      "type": "integer"
                    },
                    "next_period_at": {
                      "type": "string"
                    }
                  }
                },
                "examples": {
                  "charged": {
                    "summary": "Collected",
                    "value": {
                      "status": "CHARGED",
                      "ok": true,
                      "already_paid": false,
                      "action": "SUCCESS",
                      "tx_hash": "0x3f63e790571cc0cc6e0e4efee1954e034022a949f0b120127bdd25beedca2371",
                      "amount": "10.000000",
                      "period_index": 0
                    }
                  },
                  "confirming": {
                    "summary": "Sent, not settled - wait, never re-charge",
                    "value": {
                      "status": "CONFIRMING",
                      "ok": false,
                      "already_paid": false,
                      "action": "WAIT",
                      "tx_hash": "0x3f63e790571cc0cc6e0e4efee1954e034022a949f0b120127bdd25beedca2371",
                      "period_index": 0
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Refused. `error` names which of: `INVALID_SUBSCRIPTION`, `PERMISSION_REVOKED`, `INSUFFICIENT_BALANCE`, `INSUFFICIENT_ALLOWANCE`, `TRANSACTION_REVERTED`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Refused. `error` names which of: `NOT_DUE`, `SUBSCRIPTION_EXPIRED`, `GAS_TOO_HIGH`, `PAYMENT_ALREADY_PROCESSED`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Refused. `error` names which of: `RATE_LIMITED`, `CONCURRENCY_LIMIT`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Refused. `error` names which of: `GAS_QUOTE_UNAVAILABLE`, `RELAYER_ERROR`, `RPC_ERROR`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Operator-side limits, not payment outcomes: nothing was spent and the subscription is untouched. Retry later; a customer can do nothing about a gas spike.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/payments": {
      "post": {
        "operationId": "createPayment",
        "summary": "Create a one-time payment intent",
        "tags": [
          "One-time payments"
        ],
        "description": "Signs an intent for one payment to one recipient. The intent is the whole record - P2Flux stores nothing and learns nothing later that the token itself does not carry.\n\nThe `reference` is generated here and never accepted from the caller, deliberately: 32 random bytes carry no meaning, so a shop cannot push an order id, a customer id or an email into P2Flux by putting it in the reference. Keep your own `order -> reference` mapping.\n\n**Store the intent.** It is what verifies the payment later, and what recovers it if the callback is lost. An intent whose expiry has passed still verifies and still refunds - see `/v1/payments/verify`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "recipient": {
                    "$ref": "#/components/schemas/Address"
                  },
                  "amount": {
                    "$ref": "#/components/schemas/Amount"
                  }
                },
                "required": [
                  "recipient",
                  "amount"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The signed intent and everything the checkout needs to build the transaction.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "intent",
                    "reference",
                    "amount",
                    "expires_at",
                    "pay"
                  ],
                  "properties": {
                    "intent": {
                      "$ref": "#/components/schemas/Token"
                    },
                    "reference": {
                      "$ref": "#/components/schemas/Bytes32"
                    },
                    "amount": {
                      "$ref": "#/components/schemas/Amount"
                    },
                    "expires_at": {
                      "type": "integer",
                      "description": "Unix seconds. After this the intent cannot START a payment; it can still verify and refund one."
                    },
                    "pay": {
                      "type": "object",
                      "description": "Nothing secret - what a checkout needs to call the splitter.",
                      "properties": {
                        "chain_id": {
                          "type": "integer"
                        },
                        "splitter": {
                          "$ref": "#/components/schemas/Address"
                        },
                        "token": {
                          "$ref": "#/components/schemas/Address"
                        },
                        "recipient": {
                          "$ref": "#/components/schemas/Address"
                        },
                        "amount_units": {
                          "$ref": "#/components/schemas/AmountUnits"
                        },
                        "reference": {
                          "$ref": "#/components/schemas/Bytes32"
                        }
                      }
                    }
                  },
                  "examples": [
                    {
                      "intent": "p2f1.k1.eyJ2IjoxfQ.c2lnbmF0dXJl",
                      "reference": "0x4c2958875c223c9880b5b6262b0c069ad6727461c6443475ca2690b872e411ad",
                      "amount": "10.000000",
                      "expires_at": 1787139462,
                      "pay": {
                        "chain_id": 84532,
                        "splitter": "0x3120aa022437db5c6d0439ac7f7852ce8b38e70f",
                        "token": "0x036cbd53842c5426634e7929541ec2318f3dcf7e",
                        "recipient": "0xb4e43f3fBa5Add75395adAD366627E7d74141Fa9",
                        "amount_units": "10000000",
                        "reference": "0x4c2958875c223c9880b5b6262b0c069ad6727461c6443475ca2690b872e411ad"
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Refused. `error` names which of: `AMOUNT_OUT_OF_BOUNDS`, `INVALID_REQUEST`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Too many requests from this IP. `retry-after` says when.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Refused. `error` names which of: `INTERNAL_ERROR`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/payments/recover": {
      "post": {
        "operationId": "recoverPayment",
        "summary": "Find a payment whose transaction hash was lost",
        "tags": [
          "One-time payments"
        ],
        "description": "For when the checkout window dies between the wallet returning a hash and your server recording it: the money moved, the order looks unpaid, and there is nothing to reconcile against. Give this the intent and it finds the settling transaction on chain.\n\nYou supply no hash and no hint. The match is bound to the exact payment the intent describes, so it can never return somebody else's transaction. Pure reads and idempotent - safe to call on a schedule for any order in doubt, and it works long after the intent expired.\n\n**`found: false` with `PAYMENT_NOT_FOUND` is a statement about one block height, never a permanent verdict.** The contract does not enforce your intent's expiry, so a slow wallet can still settle afterwards and a later call will find it. Stop polling on your own business rules - never on the strength of one not-found.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "intent": {
                    "$ref": "#/components/schemas/Token"
                  }
                },
                "required": [
                  "intent"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Either the located transaction, or a not-found that names the block it was true at.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "found"
                  ],
                  "properties": {
                    "found": {
                      "type": "boolean"
                    },
                    "valid": {
                      "type": "boolean"
                    },
                    "tx_hash": {
                      "$ref": "#/components/schemas/Bytes32"
                    },
                    "code": {
                      "$ref": "#/components/schemas/ErrorCode"
                    },
                    "reference": {
                      "$ref": "#/components/schemas/Bytes32"
                    },
                    "amount": {
                      "$ref": "#/components/schemas/Amount"
                    },
                    "as_of_block": {
                      "type": "string",
                      "description": "Only on a miss. The head this answer was computed at."
                    }
                  }
                },
                "examples": {
                  "recovered": {
                    "summary": "Found and settled",
                    "value": {
                      "found": true,
                      "valid": true,
                      "tx_hash": "0xf9081f1db5f230d3eb481ec68bc8069b33a8409be4f0ea32942aee8d933188c6",
                      "amount": "0.250000"
                    }
                  },
                  "notFound": {
                    "summary": "Nothing settled AS OF this block - not a permanent answer",
                    "value": {
                      "found": false,
                      "code": "PAYMENT_NOT_FOUND",
                      "as_of_block": "45689099"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Refused. `error` names which of: `INVALID_INTENT`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Limited per IP and per intent - recovery is a background job, not a poll loop.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "`PAYMENT_RECOVERY_INCONSISTENT`: the contract says this settled and a search of every block the contract has existed for cannot find it. Abnormal - retry, and alert.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Refused. `error` names which of: `RECOVERY_UNAVAILABLE`, `RPC_BUSY`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/payments/resolve": {
      "post": {
        "operationId": "resolvePayment",
        "summary": "Authoritative terms for a checkout to display",
        "tags": [
          "One-time payments"
        ],
        "description": "What the buyer is about to pay, stated by the server rather than decoded in the browser. A tampered intent fails here, before anyone is shown - or asked to sign - anything.\n\n**This is the only endpoint that enforces expiry.** It also asks the contract whether the payment already settled, so a buyer is never sent to their wallet for a transaction that would revert.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "intent": {
                    "$ref": "#/components/schemas/Token"
                  }
                },
                "required": [
                  "intent"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Terms to display and pay against.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "recipient": {
                      "$ref": "#/components/schemas/Address"
                    },
                    "amount": {
                      "$ref": "#/components/schemas/Amount"
                    },
                    "amount_units": {
                      "$ref": "#/components/schemas/AmountUnits"
                    },
                    "token": {
                      "$ref": "#/components/schemas/Address"
                    },
                    "splitter": {
                      "$ref": "#/components/schemas/Address"
                    },
                    "chain_id": {
                      "type": "integer"
                    },
                    "reference": {
                      "$ref": "#/components/schemas/Bytes32"
                    },
                    "expires_at": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Refused. `error` names which of: `INVALID_INTENT`, `INTENT_EXPIRED`, `AMOUNT_OUT_OF_BOUNDS`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "This reference has already settled on chain. Nothing is owed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Refused. `error` names which of: `RPC_ERROR`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Refused. `error` names which of: `RPC_BUSY`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/payments/verify": {
      "post": {
        "operationId": "verifyPayment",
        "summary": "Verify a payment against the chain",
        "tags": [
          "One-time payments"
        ],
        "description": "The trust boundary. The browser saying it paid is a claim; this makes it a fact by re-reading the receipt and checking every field against the signed intent. **Never grant access on a browser message alone.**\n\nA rejected payment is HTTP 200 with `valid: false` and a `code`, not an error status - so check `valid` explicitly.\n\n**`PAYMENT_CONFIRMING` means the customer has probably paid.** The transaction is on chain and not yet settled to the required depth. Poll the same hash; never ask for a second payment.\n\n**Expiry does not apply here.** An intent whose `expires_at` has passed still verifies: expiry stops a payment being started, not one that already happened. An integration that discards expired intents loses the ability to verify - or refund - a real payment, permanently.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "intent": {
                    "$ref": "#/components/schemas/Token"
                  },
                  "tx_hash": {
                    "$ref": "#/components/schemas/Bytes32"
                  }
                },
                "required": [
                  "intent",
                  "tx_hash"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A verdict. `valid: true` settles the order; `valid: false` carries the reason.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "type": "object",
                      "required": [
                        "valid",
                        "tx_hash"
                      ],
                      "properties": {
                        "valid": {
                          "const": true
                        },
                        "tx_hash": {
                          "$ref": "#/components/schemas/Bytes32"
                        },
                        "reference": {
                          "$ref": "#/components/schemas/Bytes32"
                        },
                        "amount": {
                          "$ref": "#/components/schemas/Amount"
                        },
                        "block_number": {
                          "type": "string"
                        },
                        "block_hash": {
                          "$ref": "#/components/schemas/Bytes32"
                        }
                      }
                    },
                    {
                      "type": "object",
                      "required": [
                        "valid",
                        "code"
                      ],
                      "properties": {
                        "valid": {
                          "const": false
                        },
                        "code": {
                          "$ref": "#/components/schemas/ErrorCode"
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "settled": {
                    "summary": "Paid and settled",
                    "value": {
                      "valid": true,
                      "tx_hash": "0x2d6bbc112885a6976289e599f71003f7d310e7152ebd662c6221dffd3e0da708",
                      "reference": "0x4c2958875c223c9880b5b6262b0c069ad6727461c6443475ca2690b872e411ad",
                      "amount": "10.000000",
                      "block_number": "45688490"
                    }
                  },
                  "confirming": {
                    "summary": "Paid, still settling - poll the same hash, do not re-charge",
                    "value": {
                      "valid": false,
                      "code": "PAYMENT_CONFIRMING"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Refused. `error` names which of: `RATE_LIMITED`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/refunds/prepare": {
      "post": {
        "operationId": "prepareRefund",
        "summary": "Lock the terms of a refund",
        "tags": [
          "Refunds"
        ],
        "description": "A refund is a plain USDC transfer **from the merchant's own wallet to the wallet that paid**. There is no refund contract, no relayer and no P2Flux custody in the path: P2Flux charges no refund fee, returns none of its original commission, and the merchant pays the gas.\n\nEverything is derived from the chain. You supply identifiers and an integer amount - there is no field for a recipient anywhere in this API, because a refund endpoint that accepted one would be a withdrawal endpoint.\n\n**P2Flux keeps no refund history.** It cannot tell you whether a payment was already refunded, and calling this twice will happily prepare two valid refunds. One refund per payment is your integration's rule to enforce, and the safe place is BEFORE this call: reserve the order row atomically, then prepare.\n\nThe returned `refund_token` is short-lived and for a browser only. Do not store it - reconciliation later goes through `/v1/refunds/verify` with the original settlement, which needs no token.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "intent": {
                    "$ref": "#/components/schemas/Token"
                  },
                  "subscription": {
                    "$ref": "#/components/schemas/Token"
                  },
                  "tx_hash": {
                    "$ref": "#/components/schemas/Bytes32"
                  },
                  "period_index": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Recurring only. Refunds are per charge, never per subscription."
                  },
                  "amount": {
                    "$ref": "#/components/schemas/AmountUnits"
                  }
                },
                "required": [
                  "tx_hash",
                  "amount"
                ]
              }
            }
          },
          "description": "Identify the ORIGINAL settlement: `intent` (one-time) **or** `subscription` (recurring), plus the `tx_hash` that carried it. The capability says what was authorised, the receipt says what happened. Send exactly one of the two identifiers; sending neither is refused, and sending both prefers `intent`."
        },
        "responses": {
          "200": {
            "description": "Terms for the merchant's wallet to send.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "refund_token": {
                      "$ref": "#/components/schemas/Token"
                    },
                    "chain_id": {
                      "type": "integer"
                    },
                    "token": {
                      "$ref": "#/components/schemas/Address"
                    },
                    "merchant": {
                      "$ref": "#/components/schemas/Address"
                    },
                    "payer": {
                      "$ref": "#/components/schemas/Address"
                    },
                    "original_amount": {
                      "$ref": "#/components/schemas/Amount"
                    },
                    "original_amount_units": {
                      "$ref": "#/components/schemas/AmountUnits"
                    },
                    "refund_amount": {
                      "$ref": "#/components/schemas/Amount"
                    },
                    "refund_amount_units": {
                      "$ref": "#/components/schemas/AmountUnits"
                    },
                    "expires_at": {
                      "type": "integer",
                      "description": "Unix seconds; about fifteen minutes out."
                    }
                  }
                },
                "examples": {
                  "prepared": {
                    "value": {
                      "refund_token": "p2refund1.k1.eyJ2IjoxfQ.c2lnbmF0dXJl",
                      "chain_id": 84532,
                      "merchant": "0x4e2100539a382e7b91E77D932bE1018243660Be2",
                      "payer": "0x9B710c4Cc6A63Fc0728748Af852e2183fb936262",
                      "original_amount": "0.250000",
                      "original_amount_units": "250000",
                      "refund_amount": "0.250000",
                      "refund_amount_units": "250000"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`REFUND_AMOUNT_INVALID`: zero, non-integer, or above the ceiling. The maximum is the COMMERCIAL amount the buyer paid - so a full refund means the merchant absorbs the original P2Flux fee. For a recurring charge the ceiling excludes the gas reimbursement, which paid for a transaction that already happened.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Refused. `error` names which of: `RPC_ERROR`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/refunds/resolve": {
      "post": {
        "operationId": "resolveRefund",
        "summary": "Read a refund token back (browser)",
        "tags": [
          "Refunds"
        ],
        "description": "The terms behind a prepare token, for the browser holding it. Reading is all it does - the token is already signed, so nothing here can change where a refund goes.\n\nConsumed by the hosted checkout, not usually by a server integration: the merchant page must not be able to tell the checkout who the recipient is, or a shop that could name it could redirect a refund.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "refund_token": {
                    "$ref": "#/components/schemas/Token"
                  }
                },
                "required": [
                  "refund_token"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Exactly what P2Flux signed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "chain_id": {
                      "type": "integer"
                    },
                    "token": {
                      "$ref": "#/components/schemas/Address"
                    },
                    "merchant": {
                      "$ref": "#/components/schemas/Address"
                    },
                    "payer": {
                      "$ref": "#/components/schemas/Address"
                    },
                    "amount": {
                      "$ref": "#/components/schemas/Amount"
                    },
                    "amount_units": {
                      "$ref": "#/components/schemas/AmountUnits"
                    },
                    "expires_at": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Both permanent - a malformed or aged-out token never becomes valid. Prepare again.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/refunds/verify": {
      "post": {
        "operationId": "verifyRefund",
        "summary": "Verify a refund transfer against the chain",
        "tags": [
          "Refunds"
        ],
        "description": "Did the refund actually happen, and has it settled? Takes the ORIGINAL settlement rather than the prepare token, deliberately: a refund may need reconciling days later - after a crash, or a support ticket - and a fifteen-minute bearer token cannot answer that.\n\nA transaction hash is not a refund. This checks the receipt carries exactly one USDC transfer from the original merchant to the original payer for exactly this amount, matched **by event rather than by transaction sender** - so a Safe or smart account executing on the merchant's behalf verifies correctly.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "intent": {
                    "$ref": "#/components/schemas/Token"
                  },
                  "subscription": {
                    "$ref": "#/components/schemas/Token"
                  },
                  "tx_hash": {
                    "$ref": "#/components/schemas/Bytes32"
                  },
                  "period_index": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Recurring only. Refunds are per charge, never per subscription."
                  },
                  "refund_amount": {
                    "$ref": "#/components/schemas/AmountUnits"
                  },
                  "refund_tx_hash": {
                    "$ref": "#/components/schemas/Bytes32"
                  }
                },
                "required": [
                  "tx_hash",
                  "refund_amount",
                  "refund_tx_hash"
                ]
              }
            }
          },
          "description": "Identify the ORIGINAL settlement: `intent` (one-time) **or** `subscription` (recurring), plus the `tx_hash` that carried it. The capability says what was authorised, the receipt says what happened. Send exactly one of the two identifiers; sending neither is refused, and sending both prefers `intent`."
        },
        "responses": {
          "200": {
            "description": "Settled.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "const": "REFUNDED"
                    },
                    "refund_tx_hash": {
                      "$ref": "#/components/schemas/Bytes32"
                    },
                    "refund_amount": {
                      "$ref": "#/components/schemas/AmountUnits"
                    },
                    "original_amount": {
                      "$ref": "#/components/schemas/AmountUnits"
                    },
                    "payer": {
                      "$ref": "#/components/schemas/Address"
                    },
                    "merchant": {
                      "$ref": "#/components/schemas/Address"
                    },
                    "block_number": {
                      "type": "string"
                    }
                  }
                },
                "examples": {
                  "settled": {
                    "value": {
                      "status": "REFUNDED",
                      "refund_tx_hash": "0x7ac0b6a532f23a6aa4f0b3aa6dc13665a2a3bdbd17216331a202851b267ccf65",
                      "refund_amount": "250000",
                      "original_amount": "250000"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`REFUND_TRANSACTION_MISMATCH`: that receipt does not contain the refund it was supposed to. Never mark an order refunded on this - investigate the transaction.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "`REFUND_CONFIRMING` - the transfer is on chain and not yet settled to the required depth. **The money may already have moved.** Poll the SAME `refund_tx_hash`; sending another refund because this one has not confirmed is how a customer gets paid twice.\n\n*Changed 2026-08-21: this was previously HTTP 400. Branch on the `error` code, not the status.*",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "confirming": {
                    "value": {
                      "error": "REFUND_CONFIRMING",
                      "action": "WAIT"
                    }
                  }
                }
              }
            }
          },
          "502": {
            "description": "Refused. `error` names which of: `RPC_ERROR`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/subscriptions": {
      "post": {
        "operationId": "createSubscription",
        "summary": "Create subscription terms and a setup token",
        "tags": [
          "Recurring payments"
        ],
        "description": "Terms for a recurring authorization the customer signs once. P2Flux has no scheduler and no database: your application owns the subscription lifecycle and decides when a renewal is due; the contract enforces one charge per period against the terms that were signed.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "recipient": {
                    "$ref": "#/components/schemas/Address"
                  },
                  "amount": {
                    "$ref": "#/components/schemas/Amount"
                  },
                  "period": {
                    "type": "integer",
                    "description": "Billing period in seconds. Minimum 3600 in production."
                  },
                  "end": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Unix seconds, or 0 for no end date."
                  }
                },
                "required": [
                  "recipient",
                  "amount",
                  "period"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A setup token to hand to the checkout.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "setup_token": {
                      "$ref": "#/components/schemas/Token"
                    },
                    "expires_at": {
                      "type": "integer"
                    },
                    "chain_id": {
                      "type": "integer"
                    },
                    "contract": {
                      "$ref": "#/components/schemas/Address"
                    },
                    "amount": {
                      "$ref": "#/components/schemas/Amount"
                    },
                    "salt": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "A recurring charge carries a fixed network fee, so amounts below roughly 0.102041 USDC cannot leave the merchant anything and are refused.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/subscriptions/finalize": {
      "post": {
        "operationId": "finalizeSubscription",
        "summary": "Exchange a signature for a charge capability",
        "tags": [
          "Recurring payments"
        ],
        "description": "Validates the customer's signature against the terms and returns the `p2s2` capability that charges this subscription. Contract wallets are supported (ERC-1271).\n\n**Store the capability.** It is bearer authorization bound to one subscription's signed terms - keep it server-side, encrypted at rest, and never send it to a browser. Losing it means you cannot charge; leaking it means somebody else can, though only ever to the recipient the customer signed for.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "setup_token": {
                    "$ref": "#/components/schemas/Token"
                  },
                  "payer": {
                    "$ref": "#/components/schemas/Address"
                  },
                  "signature": {
                    "$ref": "#/components/schemas/Signature"
                  }
                },
                "required": [
                  "setup_token",
                  "payer",
                  "signature"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The capability and the subscription identity.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "subscription": {
                      "$ref": "#/components/schemas/Token"
                    },
                    "subscription_id": {
                      "$ref": "#/components/schemas/Bytes32"
                    },
                    "amount": {
                      "$ref": "#/components/schemas/Amount"
                    },
                    "period": {
                      "type": "integer"
                    },
                    "end": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`UNSUPPORTED_SIGNATURE_FORMAT`: an ERC-6492 wrapper - the account must be deployed first, because a recurring authorization is replayed for months. `SIGNATURE_VALIDATION_TOO_EXPENSIVE` is not a verdict that the signature is wrong; it means validating it cost more than we are willing to spend.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Refused. `error` names which of: `RPC_ERROR`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/subscriptions/resolve": {
      "post": {
        "operationId": "resolveSubscription",
        "summary": "Authoritative terms plus the EIP-712 payload to sign",
        "tags": [
          "Recurring payments"
        ],
        "description": "What the customer is agreeing to, stated by the server, together with the exact typed data their wallet will show them. `network_fee_estimate` is indicative and best-effort - it may be null if the chain could not be asked, and it is never what gets charged.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "setup_token": {
                    "$ref": "#/components/schemas/Token"
                  }
                },
                "required": [
                  "setup_token"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Terms and typed data.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "recipient": {
                      "$ref": "#/components/schemas/Address"
                    },
                    "amount": {
                      "$ref": "#/components/schemas/Amount"
                    },
                    "amount_units": {
                      "$ref": "#/components/schemas/AmountUnits"
                    },
                    "period": {
                      "type": "integer"
                    },
                    "start": {
                      "type": "integer"
                    },
                    "end": {
                      "type": "integer"
                    },
                    "token": {
                      "$ref": "#/components/schemas/Address"
                    },
                    "chain_id": {
                      "type": "integer"
                    },
                    "contract": {
                      "$ref": "#/components/schemas/Address"
                    },
                    "salt": {
                      "type": "string"
                    },
                    "max_gas_reimbursement": {
                      "$ref": "#/components/schemas/AmountUnits"
                    },
                    "fee_bps": {
                      "type": "integer"
                    },
                    "network_fee": {
                      "$ref": "#/components/schemas/Amount"
                    },
                    "network_fee_units": {
                      "$ref": "#/components/schemas/AmountUnits"
                    },
                    "network_fee_estimate": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "expires_at": {
                      "type": "integer"
                    },
                    "typed_data": {
                      "type": "object",
                      "description": "EIP-712 domain, types and primaryType."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Refused. `error` names which of: `INVALID_SETUP_TOKEN`, `SETUP_TOKEN_EXPIRED`, `AMOUNT_OUT_OF_BOUNDS`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/subscriptions/revoke/prepare": {
      "post": {
        "operationId": "prepareSubscriptionCancellation",
        "summary": "Calldata that cancels one subscription",
        "tags": [
          "Cancellation"
        ],
        "description": "P2Flux cannot revoke a customer's on-chain authority - only their wallet can. This returns the transaction for them to send. Accepts either the stored capability (server-side) or a cancel token (customer-side).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "subscription": {
                    "$ref": "#/components/schemas/Token"
                  }
                },
                "required": [
                  "subscription"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Unsigned calldata.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "chain_id": {
                      "type": "integer"
                    },
                    "payer": {
                      "$ref": "#/components/schemas/Address"
                    },
                    "to": {
                      "$ref": "#/components/schemas/Address"
                    },
                    "data": {
                      "type": "string"
                    },
                    "subscription_id": {
                      "$ref": "#/components/schemas/Bytes32"
                    },
                    "description": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Refused. `error` names which of: `INVALID_SUBSCRIPTION`, `INVALID_CANCEL_TOKEN`, `CANCEL_TOKEN_EXPIRED`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/subscriptions/revoke/session": {
      "post": {
        "operationId": "createCancellationSession",
        "summary": "A short-lived token safe to give a browser",
        "tags": [
          "Cancellation"
        ],
        "description": "Exchanges the stored capability for a token that carries the authorization fields needed to build a revoke transaction - and neither the customer's signature nor any ability to charge. Safe to put in a URL fragment; the contract still requires the payer's own wallet to send the transaction, so holding it grants nobody anything.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "subscription": {
                    "$ref": "#/components/schemas/Token"
                  }
                },
                "required": [
                  "subscription"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A cancel token.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "cancel_token": {
                      "$ref": "#/components/schemas/Token"
                    },
                    "expires_at": {
                      "type": "integer"
                    },
                    "subscription_id": {
                      "$ref": "#/components/schemas/Bytes32"
                    },
                    "payer": {
                      "$ref": "#/components/schemas/Address"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Refused. `error` names which of: `INVALID_SUBSCRIPTION`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/subscriptions/status": {
      "post": {
        "operationId": "subscriptionStatus",
        "summary": "Current state, read from the chain",
        "tags": [
          "Recurring payments"
        ],
        "description": "Everything about this subscription as the chain sees it. Use it to reconcile after downtime, and to decide whether a renewal is due without guessing from your own clock.\n\nThe signed terms are echoed under `terms` - compare `terms.salt` against the setup you finalized if you need to be certain a capability belongs to the plan you think it does.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "subscription": {
                    "$ref": "#/components/schemas/Token"
                  }
                },
                "required": [
                  "subscription"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Chain-derived state.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "active": {
                      "type": "boolean"
                    },
                    "revoked": {
                      "type": "boolean"
                    },
                    "revoked_confirmed": {
                      "type": "boolean",
                      "description": "Revoked deeply enough to act on. `revoked` is read at the head; only this one is settled."
                    },
                    "expired": {
                      "type": "boolean"
                    },
                    "due": {
                      "type": "boolean"
                    },
                    "charged_this_period": {
                      "type": "boolean"
                    },
                    "subscription_id": {
                      "$ref": "#/components/schemas/Bytes32"
                    },
                    "period_index": {
                      "type": [
                        "integer",
                        "null"
                      ]
                    },
                    "period_start": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "period_end": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "next_period_at": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "allowance_units": {
                      "$ref": "#/components/schemas/AmountUnits"
                    },
                    "allowance_unlimited": {
                      "type": "boolean"
                    },
                    "balance_units": {
                      "$ref": "#/components/schemas/AmountUnits"
                    },
                    "expires_at": {
                      "type": "integer"
                    },
                    "terms": {
                      "type": "object",
                      "description": "The signed authorization: payer, recipient, token, amount, amount_units, period, start, end, salt."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Refused. `error` names which of: `INVALID_SUBSCRIPTION`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Refused. `error` names which of: `RATE_LIMITED`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Refused. `error` names which of: `RPC_ERROR`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Refused. `error` names which of: `RPC_BUSY`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "security": [],
  "components": {
    "schemas": {
      "Address": {
        "type": "string",
        "pattern": "^0x[0-9a-fA-F]{40}$",
        "description": "An EVM address.",
        "examples": [
          "0xb4e43f3fBa5Add75395adAD366627E7d74141Fa9"
        ]
      },
      "Bytes32": {
        "type": "string",
        "pattern": "^0x[0-9a-f]{64}$",
        "description": "A 32-byte hex value, lowercase. Transaction hashes and references.",
        "examples": [
          "0x2d6bbc112885a6976289e599f71003f7d310e7152ebd662c6221dffd3e0da708"
        ]
      },
      "Amount": {
        "type": "string",
        "pattern": "^\\d{1,12}(\\.\\d{1,6})?$",
        "description": "A USDC amount as a decimal string, up to 6 decimal places. Never a JSON number: binary floating point cannot represent most decimal prices exactly, and this is money.",
        "examples": [
          "10.00",
          "0.250000"
        ]
      },
      "AmountUnits": {
        "type": "string",
        "pattern": "^\\d{1,20}$",
        "description": "An integer count of micro-USDC (6 decimals), as a string. 2500000 is 2.50 USDC. Used wherever a decimal would invite a rounding error - notably refund amounts.",
        "examples": [
          "2500000"
        ]
      },
      "Token": {
        "type": "string",
        "maxLength": 8192,
        "description": "A signed P2Flux capability: payment intent (p2f1.), setup token (p2setup2.), subscription capability (p2s2.), cancel token (p2cancel1.) or refund token (p2refund1.). Opaque to the caller and unforgeable - the signature is what authorises the call. Treat it as a bearer secret: keep it server-side, never in a URL query or a log."
      },
      "Signature": {
        "type": "string",
        "pattern": "^0x[0-9a-fA-F]+$",
        "maxLength": 4096,
        "description": "An EIP-712 signature. Contract wallets (ERC-1271) are supported."
      },
      "Error": {
        "type": "object",
        "description": "The uniform error envelope. `error` is the P2Flux code; `action` is what a merchant system should do about it, so integrations never hard-code that table themselves. Extra keys carry detail specific to the code (for example `retry_after`, `confirmations`, `as_of_block`).",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "$ref": "#/components/schemas/ErrorCode"
          },
          "action": {
            "$ref": "#/components/schemas/MerchantAction"
          }
        },
        "additionalProperties": true,
        "examples": [
          {
            "error": "INSUFFICIENT_BALANCE",
            "action": "CUSTOMER_ACTION_REQUIRED"
          }
        ]
      },
      "MerchantAction": {
        "type": "string",
        "enum": [
          "SUCCESS",
          "WAIT",
          "RETRY_LATER",
          "CUSTOMER_ACTION_REQUIRED",
          "STOP_SUBSCRIPTION",
          "INVALID_REQUEST"
        ],
        "description": "What to do about a result.\n\n- `SUCCESS` - done, nothing owed.\n- `WAIT` - **the money may already have moved.** The transaction exists and has not settled to the required depth. Ask again about the SAME transaction; never start another one. This is not a failure, and showing the customer an error here tells someone who has paid that they have not.\n- `RETRY_LATER` - nothing happened; the identical call is safe to repeat on your own schedule.\n- `CUSTOMER_ACTION_REQUIRED` - the customer must top up or re-approve.\n- `STOP_SUBSCRIPTION` - terminal; stop charging this subscription.\n- `INVALID_REQUEST` - permanent. Retrying returns the same answer forever; fix the request."
      },
      "ErrorCode": {
        "type": "string",
        "enum": [
          "INVALID_INTENT",
          "INTENT_EXPIRED",
          "INVALID_REFERENCE",
          "INVALID_SETUP_TOKEN",
          "SETUP_TOKEN_EXPIRED",
          "INVALID_CANCEL_TOKEN",
          "CANCEL_TOKEN_EXPIRED",
          "TERMS_MISMATCH",
          "AMOUNT_OUT_OF_BOUNDS",
          "PERIOD_OUT_OF_BOUNDS",
          "PERMISSION_NOT_FOUND",
          "TRANSACTION_NOT_FOUND",
          "PERMISSION_REVOKED",
          "ALREADY_CHARGED",
          "NOT_DUE",
          "SUBSCRIPTION_EXPIRED",
          "INVALID_SIGNATURE",
          "REFUND_CONFIRMING",
          "INVALID_REFUND_TOKEN",
          "REFUND_TOKEN_EXPIRED",
          "REFUND_AMOUNT_INVALID",
          "REFUND_WRONG_MERCHANT",
          "REFUND_TRANSACTION_MISMATCH",
          "REFUND_ORIGINAL_PAYMENT_INVALID",
          "SIGNATURE_VALIDATION_TOO_EXPENSIVE",
          "UNSUPPORTED_SIGNATURE_FORMAT",
          "PAYMENT_ALREADY_PROCESSED",
          "PAYMENT_NOT_FOUND",
          "PAYMENT_RECOVERY_INCONSISTENT",
          "RECOVERY_UNAVAILABLE",
          "WRONG_SPENDER",
          "WRONG_TOKEN",
          "INVALID_EXTRA_DATA",
          "GAS_FEE_TOO_HIGH",
          "INSUFFICIENT_ALLOWANCE",
          "INSUFFICIENT_BALANCE",
          "INVALID_SUBSCRIPTION",
          "RPC_ERROR",
          "RELAYER_ERROR",
          "INTERNAL_ERROR",
          "TRANSACTION_REVERTED",
          "INVALID_REQUEST",
          "RATE_LIMITED",
          "CONCURRENCY_LIMIT",
          "GAS_TOO_HIGH",
          "GAS_QUOTE_UNAVAILABLE",
          "PAYMENT_CONFIRMING",
          "RELAYER_TX_COST_TOO_HIGH",
          "RELAYER_BUDGET_EXCEEDED",
          "RELAYER_NOT_READY",
          "RPC_BUSY"
        ],
        "description": "Every code this API can return. Stable identifiers - branch on these, never on the human-readable text or the HTTP status alone."
      }
    }
  }
}
