HTTP API - Subscribtion Payment#
Subscription Model Overview#
Subscription payments are built on an on-chain subscription contract (Permit2 AllowanceTransfer authorization model), enabling "sign once, pull payment every billing period":
-
Buyer (payer) signs two objects off-chain:
SubscriptionTerms(the subscription terms) + a Permit2PermitSingle(allowance authorization). No further signature is needed for each period's payment; -
Seller (merchant) backend collects the Buyer's two signatures, calls the Broker API to create the subscription, and initiates a charge after each billing period becomes due (Seller-Driven);
-
Broker (facilitator) verifies the signatures and terms, then submits the on-chain transaction on behalf of the parties.
terms.facilitatormust equal the facilitator address returned by/supported.
| Dimension | Description |
|---|---|
| scheme | period |
| Asset transfer | Permit2 AllowanceTransfer → subscription contract pulls funds per period |
| Token compatibility | All ERC-20 tokens (requires a prior approve to the Permit2 canonical contract) |
| Billing period | Fixed seconds (periodMode=0) or calendar month (periodMode=1) |
| Settlement timing | Asynchronous by default; write endpoints support syncSettle=true to block until the on-chain terminal state |
| Amount semantics | Each period charges amountPerPeriod; skipped periods are never back-charged (only the current period is charged) |
| Plan management | Upgrades take effect immediately and charge the new tier's first period; downgrades are scheduled to take effect at period end |
-
Base URL:
https://web3.okx.com -
Path prefix:
/api/v6/pay/x402 -
Network: X Layer (chainId
196, CAIP-2 identifiereip155:196)
Authentication#
Endpoints fall into two authentication tiers:
-
API Key authentication: all write endpoints (create / charge / change / cancel / cancel-pending-change / finalize-expired) plus the two merchant-facing queries
chargesandpending. Requests carry theOK-ACCESS-*headers. The merchant identity behind the API Key is also the authorization baseline: it is persisted when the subscription is created, and subsequent charge / change / merchant-initiated cancel calls must come from the same merchant. -
Public read-only:
/supported,subscriptions/detail, andbuyers/{buyer}/*require no API Key (everything they return is derivable from on-chain data) and can be called directly by the Buyer; rate limits still apply.
| Header | Required | Description |
|---|---|---|
OK-ACCESS-KEY | Yes | API Key |
OK-ACCESS-SIGN | Yes | Request signature |
OK-ACCESS-PASSPHRASE | Yes | API passphrase |
OK-ACCESS-TIMESTAMP | Yes | ISO 8601 timestamp |
Content-Type | Yes | Must be application/json for POST requests |
All responses use a unified business envelope:
{
"code": "0",
"msg": "",
"data": { /* business fields */ }
}
On business errors, code is non-"0", data is null, and msg carries a machine-readable error identifier (e.g. period_not_due). See the Error Codes section at the end.
Common Conventions#
syncSettle (all write endpoints)#
Every write endpoint body supports the syncSettle field:
-
true: block and poll until the on-chain terminal state or a timeout (default 5000ms); the returneddatareflects the latest persisted state; -
false/ omitted: return immediately after submission (state is usuallypending); poll the query endpoints afterwards.
Field Representation#
-
Amounts (
amountPerPeriod/initialChargeAmount/amount): decimal strings in atomic units; -
Times (
periodSec/startAt/termsDeadline/expiration/deadline): Unix seconds; -
subId/salt/nonce/permitHash/changeFromSubId: bytes32 as0x+ 64 hex chars; -
Addresses:
0x+ 40 hex chars, lowercase.
Enums#
| Enum | Values |
|---|---|
Subscription state state | 0 pending / 1 active / 2 completed / 3 canceled / 4 changed / 99 failed (local state for off-chain submission failure) |
Charge record state charge.state | 0 pending / 1 success / 2 failed |
Charge type chargeType | 1 initial / 2 periodic / 3 first period after downgrade / 4 expired-finalize marker |
Pending change state pendingChange.state | 0 pending / 1 activated / 2 canceled / 3 expired |
Change effectiveness changeEffectiveAt | 0 none (create) / 1 immediate (upgrade) / 2 period_end (downgrade) |
Cancel initiator cancelAuth.initiator | 0 payer / 1 merchant |
Period mode periodMode | 0 fixed_seconds / 1 calendar_month |
Period Mode (periodMode)#
| Mode | periodSec requirement | Period boundary |
|---|---|---|
0 fixed seconds | Must be > 0 | startAt + n × periodSec |
1 calendar month | Must be 0 | addMonths(billingAnchorAt, n) (clamped to month end, preserving time of day) |
Key calendar-month semantics:
-
The anchor never drifts: every boundary is computed by adding n months to the original anchor
billingAnchorAt, e.g.1/31 12:00 → 2/28 → 3/31 → 4/30; it does not chain-drift onto a day-28 rhythm; -
The exact boundary instant belongs to the next period;
-
billingAnchorAt: for a plain new subscription it equalsstartAt; upgrades / downgrade activation inherit the old subscription's anchor (the month-end rhythm continues); whenstartAt=0it is backfilled from the chain; -
All timestamps are UTC;
-
Skipped periods are never back-charged: missed intermediate periods release their reserved allowance; a charge only pulls the current period (identical semantics in both modes).
1. /api/v6/pay/x402/supported#
/api/v6/pay/x402/supported
Queries the schemes, networks, and signer list supported by the Broker (no API Key required). Call this endpoint before subscription integration to obtain the subscription contract address and facilitator address.
Subscription capability is advertised as entries with scheme="period" in the kinds array (emitted dynamically based on rollout switches and per-chain contract configuration):
kinds[].extra subfield | Description |
|---|---|
facilitatorAddress | Facilitator EOA address. The Buyer must copy it verbatim into terms.facilitator — the contract requires the transaction submitter to match it |
subscriptionContract | Subscription contract address; the Permit2 PermitSingle.spender must equal it |
permit2Contract | Permit2 canonical contract address; the target of the Buyer's first-layer ERC-20 approve |
signers[network] lists all available facilitator EOAs on that chain, so Sellers can verify that a given address belongs to this facilitator.
Request Example#
curl --location --request GET 'https://web3.okx.com/api/v6/pay/x402/supported'
Response Example#
{
"code": "0",
"msg": "",
"data": {
"kinds": [
{ "x402Version": 2, "scheme": "exact", "network": "eip155:196" },
{ "x402Version": 2, "scheme": "aggr_deferred", "network": "eip155:196" },
{
"x402Version": 2,
"scheme": "period",
"network": "eip155:196",
"extra": {
"facilitatorAddress": "0xFacilitatorEOA...",
"subscriptionContract": "0xSubscriptionContract...",
"permit2Contract": "0x000000000022d473030f116ddee9f6b43ac78ba3"
}
}
],
"extensions": [],
"signers": {
"eip155:196": [
"0xFacilitatorEOA...",
"0xFacilitatorEOA2..."
]
}
}
}
2. /api/v6/pay/x402/subscriptions#
/api/v6/pay/x402/subscriptions
Creates a subscription. The Buyer's two signatures (SubscriptionTerms + Permit2 PermitSingle) are forwarded and submitted by the Seller backend; the contract creates the subscription and charges the initial amount per the initial-charge parameters.
Request Parameters#
| Parameter | Type | Required | Description |
|---|---|---|---|
chainIndex | Long | Yes | Chain index, e.g. 196 |
terms | Object | Yes | Subscription terms, see Common Data Structures: SubscriptionTerms |
permit | Object | Yes | Permit2 authorization, see Common Data Structures: PermitSingle |
termsSig | String | Yes | EIP-712 signature over terms (65-byte hex), signer = terms.payer |
permitSig | String | Yes | EIP-712 signature over permit (65-byte hex), signer = terms.payer |
syncSettle | Boolean | No | See Common Conventions: syncSettle |
Constraints:
-
On create,
terms.changeFromSubIdmust be all zeros andterms.changeEffectiveAtmust be0; -
Initial-charge rules: when
initialChargePeriods > 0,initialChargeAmount ≤ initialChargePeriods × amountPerPeriodis required;initialChargePeriods = 0 with initialChargeAmount > 0is a pre-start upfront fee (must be≤ amountPerPeriodand requiresstartAt > now); -
The permit allowance must cover the subscription's full commitment, and
permit.details.expirationmust not be earlier than the end of the subscription's service window; -
Creation moves funds, so compliance screening runs on both
payerandmerchant; blocklisted addresses are rejected.
Response Parameters#
| Parameter | Type | Description |
|---|---|---|
subId | String | Subscription ID (bytes32, = the EIP-712 digest of terms) |
txHash | String | Creation transaction hash |
state | Integer | Subscription state, see Enums |
Request Example (calendar-month billing)#
curl --location --request POST 'https://web3.okx.com/api/v6/pay/x402/subscriptions' \
--header 'Content-Type: application/json' \
--header 'OK-ACCESS-KEY: 37c541a1-****-****-****-10fe7a038418' \
--header 'OK-ACCESS-SIGN: leaV********3uw=' \
--header 'OK-ACCESS-PASSPHRASE: 1****6' \
--header 'OK-ACCESS-TIMESTAMP: 2026-04-01T12:21:41.274Z' \
--data '{
"chainIndex": 196,
"terms": {
"payer": "0x1111111111111111111111111111111111111111",
"merchant": "0x2222222222222222222222222222222222222222",
"facilitator": "0xFacilitatorEOA...",
"token": "0x4ae46a509f6b1d9056937ba4500cb143933d2dc8",
"amountPerPeriod": "5000000",
"periodSec": 0,
"maxPeriods": 12,
"startAt": 0,
"initialChargePeriods": 1,
"initialChargeAmount": "5000000",
"termsDeadline": 1781000000,
"permitHash": "0xab12...permitStructHash...cd34",
"salt": "0x7f3a...random32bytes...9e01",
"planId": "0x0000...keccak(pro_monthly)...0000",
"planTier": 2,
"changeFromSubId": "0x0000000000000000000000000000000000000000000000000000000000000000",
"changeEffectiveAt": 0,
"periodMode": 1
},
"permit": {
"details": {
"token": "0x4ae46a509f6b1d9056937ba4500cb143933d2dc8",
"amount": "60000000",
"expiration": 1812600000,
"nonce": 5
},
"spender": "0xSubscriptionContract...",
"sigDeadline": "1781000600"
},
"termsSig": "0x<65-byte hex>",
"permitSig": "0x<65-byte hex>",
"syncSettle": true
}'
Fixed-seconds mode: set
periodMode=0and a positiveperiodSec(e.g.2592000for monthly).
Response Example#
{
"code": "0",
"msg": "",
"data": {
"subId": "0x9a8b...termsDigest...c7d6",
"txHash": "0xabc...create...def",
"state": 1
}
}
3. /api/v6/pay/x402/subscriptions/charge#
/api/v6/pay/x402/subscriptions/charge
Periodic charge. Initiated by the Seller backend once the billing period is due; no further Buyer signature is required. Only the merchant (API Key) that created the subscription may call it.
Request Parameters#
| Parameter | Type | Required | Description |
|---|---|---|---|
subId | String | Yes | Subscription ID |
syncSettle | Boolean | No | See Common Conventions: syncSettle |
Validation chain: the subscription must be active; not all periods charged yet (otherwise all_periods_charged); the current period must be due (otherwise period_not_due); when due, compliance screening runs on payer and merchant.
Response Parameters#
| Parameter | Type | Description |
|---|---|---|
subId | String | Original subscription ID (echoes the request) |
period | Long | Period number charged this time (= the current period; skipped periods are never back-charged) |
txHash | String | Charge transaction hash |
state | Integer | Charge record state: 0 pending / 1 success / 2 failed |
planChangeTriggered | Boolean | Whether this period triggered a scheduled downgrade switch |
newSubId | String | New subscription ID after the downgrade when planChangeTriggered=true; otherwise null |
Request Example#
curl --location --request POST 'https://web3.okx.com/api/v6/pay/x402/subscriptions/charge' \
--header 'Content-Type: application/json' \
--header 'OK-ACCESS-KEY: 37c541a1-****-****-****-10fe7a038418' \
--header 'OK-ACCESS-SIGN: leaV********3uw=' \
--header 'OK-ACCESS-PASSPHRASE: 1****6' \
--header 'OK-ACCESS-TIMESTAMP: 2026-04-01T12:21:41.274Z' \
--data '{ "subId": "0x9a8b...c7d6", "syncSettle": true }'
Response Example — Regular Charge#
{
"code": "0",
"msg": "",
"data": {
"subId": "0x9a8b...c7d6",
"period": 4,
"txHash": "0xabc...charge...def",
"state": 1,
"planChangeTriggered": false,
"newSubId": null
}
}
Response Example — Downgrade Switch Triggered This Period#
{
"code": "0",
"msg": "",
"data": {
"subId": "0x9a8b...c7d6",
"period": 4,
"txHash": "0xabc...activate...def",
"state": 1,
"planChangeTriggered": true,
"newSubId": "0x2c1d...newDowngradeSubId...8f0a"
}
}
4. /api/v6/pay/x402/subscriptions/change#
/api/v6/pay/x402/subscriptions/change
Plan upgrade / downgrade. An upgrade (changeEffectiveAt=1) takes effect immediately and charges the new tier's first period; a downgrade (changeEffectiveAt=2) is scheduled to the end of the current period and is switched by the next charge. Only the merchant that created the subscription may call it.
Request Parameters#
| Parameter | Type | Required | Description |
|---|---|---|---|
chainIndex | Long | Yes | Chain index |
oldSubId | String | Yes | Old subscription ID (informational; the server relies on newTerms.changeFromSubId) |
newTerms | Object | Yes | New terms: changeFromSubId must equal the old subId, see Common Data Structures: SubscriptionTerms |
permit | Object | Yes | New Permit2 PermitSingle (covering the new tier's full commitment) |
termsSig / permitSig | String | Yes | EIP-712 signatures, signer = newTerms.payer |
syncSettle | Boolean | No | See Common Conventions: syncSettle |
Direction rules and constraints:
-
newTerms.planTier > old planTierrequireschangeEffectiveAt=1(upgrade);<requireschangeEffectiveAt=2(downgrade); equal tiers are rejected (tier_same); -
The following must be identical across old and new subscriptions:
payer/merchant/facilitator/token/periodSec/periodMode(the period mode cannot be switched); -
The old subscription must be
activewith no scheduled-but-not-yet-effective downgrade (otherwisepending_change_exists); -
For downgrades,
newTerms.initialChargeAmountmust be 0; -
newTerms.startAtrules: if the old subscription has not started yet (pre-start), it must equal the old subscription'sstartAt; for downgrades and for in-effect upgrades in fixed mode it must be 0; for in-effect calendar-month upgrades it may equal the start of the old subscription's current period (aligned upgrade — inherits the old anchor; period 1 retroactively covers the old current period and is charged in full) or 0 (re-anchor from the transaction time).
Response Parameters#
| Parameter | Type | Description |
|---|---|---|
newSubId | String | New subscription ID (= digest of newTerms). Effective immediately for upgrades; the pending new subId for downgrades |
txHash | String | Transaction hash |
state | Integer | Upgrade: new subscription's state; downgrade: old subscription's state (still 1 active) |
Request Example — Upgrade (immediate)#
curl --location --request POST 'https://web3.okx.com/api/v6/pay/x402/subscriptions/change' \
--header 'Content-Type: application/json' \
--header 'OK-ACCESS-KEY: 37c541a1-****-****-****-10fe7a038418' \
--header 'OK-ACCESS-SIGN: leaV********3uw=' \
--header 'OK-ACCESS-PASSPHRASE: 1****6' \
--header 'OK-ACCESS-TIMESTAMP: 2026-04-01T12:21:41.274Z' \
--data '{
"chainIndex": 196,
"oldSubId": "0x9a8b...c7d6",
"newTerms": {
"payer": "0x1111111111111111111111111111111111111111",
"merchant": "0x2222222222222222222222222222222222222222",
"facilitator": "0xFacilitatorEOA...",
"token": "0x4ae46a509f6b1d9056937ba4500cb143933d2dc8",
"amountPerPeriod": "20000000",
"periodSec": 0,
"maxPeriods": 12,
"startAt": 0,
"initialChargePeriods": 0,
"initialChargeAmount": "0",
"termsDeadline": 1781200000,
"permitHash": "0x<new permit struct hash>",
"salt": "0x<new random32bytes>",
"planId": "0x<keccak(enterprise_monthly)>",
"planTier": 3,
"changeFromSubId": "0x9a8b...c7d6",
"changeEffectiveAt": 1,
"periodMode": 1
},
"permit": {
"details": { "token": "0x4ae46a509f6b1d9056937ba4500cb143933d2dc8", "amount": "240000000", "expiration": 1812600000, "nonce": 6 },
"spender": "0xSubscriptionContract...",
"sigDeadline": "1781200600"
},
"termsSig": "0x<65-byte hex>",
"permitSig": "0x<65-byte hex>",
"syncSettle": true
}'
Response Example — Upgrade#
{
"code": "0",
"msg": "",
"data": { "newSubId": "0x6b5c...upgradedSubId...a1f2", "txHash": "0xabc...upgrade...def", "state": 1 }
}
Response Example — Downgrade (scheduled to period end)#
Request-body differences: newTerms.changeEffectiveAt=2, a lower planTier, startAt=0, initialChargeAmount="0".
{
"code": "0",
"msg": "",
"data": { "newSubId": "0x2c1d...pendingNewSubId...8f0a", "txHash": "0xabc...schedule...def", "state": 1 }
}
The downgrade response's
stateis the old subscription's state (still1active);newSubIdis the pending new subId, which only takes effect after the nextchargetriggers activation.
5. /api/v6/pay/x402/subscriptions/cancel#
/api/v6/pay/x402/subscriptions/cancel
Cancels a subscription. Requires an off-chain CancelAuth signature (either payer or merchant can initiate). After cancellation, future charges stop and the reserved allowance is released.
Request Parameters#
| Parameter | Type | Required | Description |
|---|---|---|---|
subId | String | Yes | Subscription ID (cross-checked against cancelAuth.subId) |
cancelAuth | Object | Yes | Cancellation authorization, see Common Data Structures: CancelAuth |
syncSettle | Boolean | No | See Common Conventions: syncSettle |
Validation: the subscription must be active; cancelAuth.subId = body subId; deadline > now; the recovered signer = payer (initiator=0) or merchant (initiator=1); for initiator=1 the caller's API Key must also be the subscription's creating merchant.
Response Parameters#
| Parameter | Type | Description |
|---|---|---|
subId | String | Subscription ID |
txHash | String | Reserved field, currently returns null (the cancel transaction can be observed via subscription detail / charge records) |
state | Integer | Subscription state, 3 canceled on success |
Request Example#
curl --location --request POST 'https://web3.okx.com/api/v6/pay/x402/subscriptions/cancel' \
--header 'Content-Type: application/json' \
--header 'OK-ACCESS-KEY: 37c541a1-****-****-****-10fe7a038418' \
--header 'OK-ACCESS-SIGN: leaV********3uw=' \
--header 'OK-ACCESS-PASSPHRASE: 1****6' \
--header 'OK-ACCESS-TIMESTAMP: 2026-04-01T12:21:41.274Z' \
--data '{
"subId": "0x9a8b...c7d6",
"cancelAuth": {
"action": 0,
"initiator": 0,
"subId": "0x9a8b...c7d6",
"nonce": "0x<random32bytes>",
"deadline": 1781300000,
"signature": "0x<65-byte hex, signed by payer>"
},
"syncSettle": true
}'
Response Example#
{
"code": "0",
"msg": "",
"data": { "subId": "0x9a8b...c7d6", "txHash": null, "state": 3 }
}
6. /api/v6/pay/x402/subscriptions/cancel-pending-change#
/api/v6/pay/x402/subscriptions/cancel-pending-change
Cancels a scheduled but not-yet-effective downgrade. Only the payer can sign the authorization.
Request Parameters#
| Parameter | Type | Required | Description |
|---|---|---|---|
subId | String | Yes | Subscription ID |
cancelAuth | Object | Yes | See Common Data Structures: PendingChangeCancelAuth; must include the target newSubId |
syncSettle | Boolean | No | See Common Conventions: syncSettle |
Validation: a downgrade schedule in pending state must exist (otherwise no_pending_change_or_not_pending); cancelAuth.subId = the schedule's subId; cancelAuth.newSubId = the schedule's newSubId (otherwise pending_cancel_target_mismatch); deadline > now; the recovered signer = the subscription's payer.
newSubIdcan be obtained from thependingPlanChange.newSubIdfield of the subscription detail response.
Response Parameters#
| Parameter | Type | Description |
|---|---|---|
subId | String | Subscription ID |
txHash | String | Transaction hash associated with the downgrade schedule record |
state | Integer | Pending-change state (not the subscription state): 2 canceled on success |
Request Example#
curl --location --request POST 'https://web3.okx.com/api/v6/pay/x402/subscriptions/cancel-pending-change' \
--header 'Content-Type: application/json' \
--header 'OK-ACCESS-KEY: 37c541a1-****-****-****-10fe7a038418' \
--header 'OK-ACCESS-SIGN: leaV********3uw=' \
--header 'OK-ACCESS-PASSPHRASE: 1****6' \
--header 'OK-ACCESS-TIMESTAMP: 2026-04-01T12:21:41.274Z' \
--data '{
"subId": "0x9a8b...c7d6",
"cancelAuth": {
"subId": "0x9a8b...c7d6",
"newSubId": "0x2c1d...pendingNewSubId...8f0a",
"nonce": "0x<random32bytes>",
"deadline": 1781300000,
"signature": "0x<65-byte hex, signed by payer only>"
},
"syncSettle": true
}'
Response Example#
{
"code": "0",
"msg": "",
"data": { "subId": "0x9a8b...c7d6", "txHash": "0xabc...schedule...def", "state": 2 }
}
7. /api/v6/pay/x402/subscriptions/finalize-expired#
/api/v6/pay/x402/subscriptions/finalize-expired
Finalizes a subscription whose service window has ended but which was never terminated, releasing its reserved allowance in the subscription contract so the Buyer can use it for a new subscription.
Request Parameters#
| Parameter | Type | Required | Description |
|---|---|---|---|
subId | String | Yes | Subscription ID |
Validation: the subscription is active and its service window has ended (fixed mode: now ≥ startAt + maxPeriods × periodSec; calendar month: now ≥ addMonths(anchor, maxPeriods); otherwise not_ended).
Response Parameters#
| Parameter | Type | Description |
|---|---|---|
subId | String | Subscription ID |
txHash | String | Reserved field, currently returns null |
state | Integer | Reserved field, currently returns null (query the subscription detail for the terminal state; 2 completed after finalization) |
Request Example#
curl --location --request POST 'https://web3.okx.com/api/v6/pay/x402/subscriptions/finalize-expired' \
--header 'Content-Type: application/json' \
--header 'OK-ACCESS-KEY: 37c541a1-****-****-****-10fe7a038418' \
--header 'OK-ACCESS-SIGN: leaV********3uw=' \
--header 'OK-ACCESS-PASSPHRASE: 1****6' \
--header 'OK-ACCESS-TIMESTAMP: 2026-04-01T12:21:41.274Z' \
--data '{ "subId": "0x9a8b...c7d6" }'
Response Example#
{
"code": "0",
"msg": "",
"data": { "subId": "0x9a8b...c7d6", "txHash": null, "state": null }
}
8. /api/v6/pay/x402/subscriptions/detail#
/api/v6/pay/x402/subscriptions/detail
Queries subscription details (public read-only, no API Key). The Buyer can call it directly — for example to read pendingPlanChange.newSubId when signing a cancel-pending-change authorization.
Request Parameters#
| Parameter | Location | Type | Required | Description |
|---|---|---|---|---|
subId | query | String | Yes | Subscription ID |
Response Parameters#
| Parameter | Type | Description |
|---|---|---|
subId / state / payer / merchant / token | Base fields | |
amountPerPeriod / periodSec / maxPeriods / startAt | Subscription terms (periodSec=0 in calendar-month mode) | |
periodMode | Integer | 0 fixed seconds / 1 calendar month |
billingAnchorAt | Long | Calendar-month billing anchor (Unix seconds); 0 = pending on-chain backfill; ignored in fixed mode |
lastChargedPeriod | Long | Last period number charged |
totalPulled | String | Cumulative amount pulled (atomic units) |
planId / planTier | String / Integer | Plan identifier / tier |
changedToSubId | String | New subId after an upgrade/downgrade switch (null if none) |
isActive | Boolean | state=1 and the service window has not ended |
serviceEnded | Boolean | state=1 but the service window has ended (not finalized) |
currentPeriod | Long | Clock-derived current period number (clamped to maxPeriods). Do not use it to detect expiry — use serviceEnded / isActive |
elapsedPeriods | Long | Actual elapsed period number (unclamped, for display); > maxPeriods means the service window has ended |
nextChargeableAt | Long | Next chargeable time (Unix seconds); null when all periods have been charged |
pendingPlanChange | Object | Embedded pending downgrade (null if none): subId / newSubId / effectiveFromPeriod / state |
Request Example#
curl --location --request GET 'https://web3.okx.com/api/v6/pay/x402/subscriptions/detail?subId=0x9a8b...c7d6'
Response Example#
{
"code": "0",
"msg": "",
"data": {
"subId": "0x9a8b...c7d6",
"state": 1,
"payer": "0x1111111111111111111111111111111111111111",
"merchant": "0x2222222222222222222222222222222222222222",
"token": "0x4ae46a509f6b1d9056937ba4500cb143933d2dc8",
"amountPerPeriod": "20000000",
"periodSec": 0,
"periodMode": 1,
"maxPeriods": 12,
"startAt": 1781001000,
"billingAnchorAt": 1781001000,
"lastChargedPeriod": 3,
"totalPulled": "60000000",
"planId": "0x<keccak(pro_monthly)>",
"planTier": 2,
"changedToSubId": null,
"isActive": true,
"serviceEnded": false,
"currentPeriod": 4,
"elapsedPeriods": 4,
"nextChargeableAt": 1788777000,
"pendingPlanChange": {
"subId": "0x9a8b...c7d6",
"newSubId": "0x2c1d...pendingNewSubId...8f0a",
"effectiveFromPeriod": 5,
"state": 0
}
}
}
9. /api/v6/pay/x402/subscriptions/charges#
/api/v6/pay/x402/subscriptions/charges
Queries a subscription's charge records (merchant endpoint, API Key required).
Request Parameters#
| Parameter | Location | Type | Required | Default | Description |
|---|---|---|---|---|---|
subId | query | String | Yes | Subscription ID | |
limit | query | Integer | No | 50 | 1..100, ordered by creation time descending |
offset | query | Integer | No | 0 | ≥ 0 |
Response Parameters#
charges array, each item:
| Parameter | Type | Description |
|---|---|---|
subId | String | Subscription ID |
period | Long | Period number |
chargeType | Integer | 1 initial / 2 periodic / 3 first period after downgrade / 4 expired-finalize marker |
amount | String | Charge amount (atomic units) |
state | Integer | 0 pending / 1 success / 2 failed |
txHash | String | Transaction hash |
planChangeTriggered | Boolean | Whether this charge triggered a downgrade switch |
newSubId | String | New subscription ID when a downgrade was triggered |
Request Example#
curl --location --request GET 'https://web3.okx.com/api/v6/pay/x402/subscriptions/charges?subId=0x9a8b...c7d6&limit=50&offset=0' \
--header 'OK-ACCESS-KEY: 37c541a1-****-****-****-10fe7a038418' \
--header 'OK-ACCESS-SIGN: leaV********3uw=' \
--header 'OK-ACCESS-PASSPHRASE: 1****6' \
--header 'OK-ACCESS-TIMESTAMP: 2026-04-01T12:21:41.274Z'
Response Example#
{
"code": "0",
"msg": "",
"data": {
"charges": [
{ "subId": "0x9a8b...c7d6", "period": 3, "chargeType": 2, "amount": "20000000", "state": 1, "txHash": "0x...p3...", "planChangeTriggered": false, "newSubId": null },
{ "subId": "0x9a8b...c7d6", "period": 1, "chargeType": 1, "amount": "20000000", "state": 1, "txHash": "0x...init...", "planChangeTriggered": false, "newSubId": null }
]
}
}
10. /api/v6/pay/x402/subscriptions/pending#
/api/v6/pay/x402/subscriptions/pending
Queries the subscription's most recent downgrade schedule record (any state, so terminal states canceled / activated / expired are observable; merchant endpoint, API Key required). All fields are null when no record exists.
Request Parameters#
| Parameter | Location | Type | Required | Description |
|---|---|---|---|---|
subId | query | String | Yes | Subscription ID |
Response Parameters#
| Parameter | Type | Description |
|---|---|---|
subId | String | Subscription ID |
newSubId | String | New subscription ID targeted by the downgrade |
effectiveFromPeriod | Long | Period from which the change takes effect |
state | Integer | 0 pending / 1 activated / 2 canceled / 3 expired |
Request Example#
curl --location --request GET 'https://web3.okx.com/api/v6/pay/x402/subscriptions/pending?subId=0x9a8b...c7d6' \
--header 'OK-ACCESS-KEY: 37c541a1-****-****-****-10fe7a038418' \
--header 'OK-ACCESS-SIGN: leaV********3uw=' \
--header 'OK-ACCESS-PASSPHRASE: 1****6' \
--header 'OK-ACCESS-TIMESTAMP: 2026-04-01T12:21:41.274Z'
Response Example — Schedule Exists#
{
"code": "0",
"msg": "",
"data": { "subId": "0x9a8b...c7d6", "newSubId": "0x2c1d...8f0a", "effectiveFromPeriod": 5, "state": 0 }
}
Response Example — No Schedule#
{
"code": "0",
"msg": "",
"data": { "subId": null, "newSubId": null, "effectiveFromPeriod": null, "state": null }
}
11. /api/v6/pay/x402/buyers/{buyer}/allowance-status#
/api/v6/pay/x402/buyers/{buyer}/allowance-status
Queries the Buyer's two-layer allowance status (public read-only, no API Key). The Buyer SDK uses it to build the PermitSingle and to decide whether an ERC-20 approve is needed first. Results are not cached (in-flight transactions may change the nonce).
Request Parameters#
| Parameter | Location | Type | Required | Description |
|---|---|---|---|---|
buyer | path | String | Yes | Payer address |
token | query | String | Yes | ERC-20 token address |
chainIndex | query | Long | Yes | Chain index |
Response Parameters#
| Parameter | Type | Description |
|---|---|---|
permit2Allowance | String | Layer 1: ERC20.allowance(buyer, Permit2). If insufficient, the Buyer must first call token.approve(permit2Contract, ...) |
approvedAmount | String | Layer 2: allowance granted inside Permit2 to the subscription contract |
expiration | Long | Expiration of the layer-2 allowance |
nonce | Long | Current Permit2 nonce; sign the next permit with this exact value |
reservedAmount | String | Allowance already reserved by active subscriptions in the subscription contract |
reservedExpiration | Long | Expiration of the reserved amount; the lower bound for a new permit's expiration |
tokenBalance | String | Buyer's token balance (for UX hints) |
availableAmount | String | Derived: max(approvedAmount - reservedAmount, 0), the headroom available for new subscriptions |
subscriptionContract | String | Subscription contract address (the PermitSingle.spender) |
permit2Contract | String | Permit2 contract address (the target of the layer-1 approve) |
Request Example#
curl --location --request GET 'https://web3.okx.com/api/v6/pay/x402/buyers/0x1111111111111111111111111111111111111111/allowance-status?token=0x4ae46a509f6b1d9056937ba4500cb143933d2dc8&chainIndex=196'
Response Example#
{
"code": "0",
"msg": "",
"data": {
"approvedAmount": "100000000",
"expiration": 1812600000,
"nonce": 5,
"reservedAmount": "40000000",
"reservedExpiration": 1812600000,
"tokenBalance": "523000000",
"availableAmount": "60000000",
"permit2Allowance": "115792089237316195423570985008687907853269984665640564039457584007913129639935",
"subscriptionContract": "0xSubscriptionContract...",
"permit2Contract": "0x000000000022d473030f116ddee9f6b43ac78ba3"
}
}
12. /api/v6/pay/x402/buyers/{buyer}/subscriptions#
/api/v6/pay/x402/buyers/{buyer}/subscriptions
Queries the Buyer's own subscription list (public read-only, no API Key). No merchant identity information is returned (no merchant / facilitator / subscriptionContract).
Request Parameters#
| Parameter | Location | Type | Required | Default | Description |
|---|---|---|---|---|---|
buyer | path | String | Yes | Payer address | |
limit | query | Integer | No | 50 | 1..100, ordered by creation time descending |
offset | query | Integer | No | 0 | ≥ 0 |
Response Parameters#
subscriptions array, each item:
| Parameter | Type | Description |
|---|---|---|
chainIndex | Long | Chain index |
subId / state / payer / token | Base fields | |
amountPerPeriod / periodSec / maxPeriods / startAt | Subscription terms | |
periodMode / billingAnchorAt | Period mode / calendar-month anchor | |
initialChargePeriods / initialChargeAmount | Initial-charge parameters | |
lastChargedPeriod / totalPulled | Charging progress | |
planId / planTier / changedToSubId | Plan information | |
isActive / serviceEnded / currentPeriod / elapsedPeriods / nextChargeableAt | Derived fields, same semantics as the subscription detail endpoint |
Request Example#
curl --location --request GET 'https://web3.okx.com/api/v6/pay/x402/buyers/0x1111111111111111111111111111111111111111/subscriptions?limit=20&offset=0'
Response Example#
{
"code": "0",
"msg": "",
"data": {
"subscriptions": [
{
"chainIndex": 196,
"subId": "0x9a8b...c7d6",
"state": 1,
"payer": "0x1111111111111111111111111111111111111111",
"token": "0x4ae46a509f6b1d9056937ba4500cb143933d2dc8",
"amountPerPeriod": "20000000",
"periodSec": 0,
"periodMode": 1,
"maxPeriods": 12,
"startAt": 1781001000,
"billingAnchorAt": 1781001000,
"initialChargePeriods": 0,
"initialChargeAmount": "0",
"lastChargedPeriod": 3,
"totalPulled": "60000000",
"planId": "0x<keccak(pro_monthly)>",
"planTier": 2,
"changedToSubId": null,
"isActive": true,
"serviceEnded": false,
"currentPeriod": 4,
"elapsedPeriods": 4,
"nextChargeableAt": 1788777000
}
]
}
}
Common Data Structures#
SubscriptionTerms#
The subscription terms signed by the Buyer — 17 fields, all included in the EIP-712 signature (except planId). subId = the EIP-712 digest of the terms.
| Field | Type | On-chain type | Required | Description |
|---|---|---|---|---|
payer | String | address | Yes | Payer (the signer) |
merchant | String | address | Yes | Receiving merchant (on-chain address) |
facilitator | String | address | Yes | Facilitator EOA, must be copied verbatim from /supported |
token | String | address | Yes | ERC-20 token address |
amountPerPeriod | String | uint160 | Yes | Amount per period (atomic units) |
periodSec | Long | uint64 | Yes | Period in seconds; must be 0 in calendar-month mode |
maxPeriods | Long | uint32 | Yes | Total number of periods |
startAt | Long | uint64 | Yes | Start time; 0 = the contract uses the on-chain timestamp; a non-zero value must not be earlier than now |
initialChargePeriods | Long | uint32 | Yes | Number of periods covered by the initial charge (0 = no separate initial charge) |
initialChargeAmount | String | uint160 | Yes | Initial charge amount (atomic units) |
termsDeadline | Long | uint64 | Yes | Terms signature validity deadline |
permitHash | String | bytes32 | Yes | = the EIP-712 struct hash of the PermitSingle (binds the permit) |
salt | String | bytes32 | Yes | Random anti-replay value generated by the Buyer |
planId | String | bytes32 | Yes | Plan ID (business identifier, not part of the on-chain signature) |
planTier | Integer | uint8 | Yes | Plan tier (> 0; used to compare upgrade/downgrade direction) |
changeFromSubId | String | bytes32 | Yes | Create = all zeros; upgrade/downgrade = the old subId |
changeEffectiveAt | Integer | uint8 | Yes | 0 none / 1 immediate / 2 period_end |
periodMode | Integer | uint8 | Yes | 0 fixed seconds / 1 calendar month |
PermitSingle#
Permit2 AllowanceTransfer authorization object.
| Field | Type | Description |
|---|---|---|
details.token | String | Token address (must equal terms.token) |
details.amount | String | Allowance amount (uint160 string); must cover the subscription's full commitment |
details.expiration | Long | Allowance expiration (uint48 seconds); must not be earlier than the end of the subscription's service window |
details.nonce | Long | Permit2 nonce (uint48), obtained from the allowance-status endpoint |
spender | String | Must equal the subscription contract address |
sigDeadline | String | Permit signature validity deadline (uint256 string) |
CancelAuth#
Subscription cancellation authorization (signed by payer or merchant).
| Field | Type | Description |
|---|---|---|
action | Integer | Fixed 0 (cancel_subscription) |
initiator | Integer | 0 payer / 1 merchant |
subId | String | bytes32, the target subscription ID |
nonce | String | bytes32, anti-replay |
deadline | Long | Unix seconds |
signature | String | EIP-712 signature (65-byte hex) |
PendingChangeCancelAuth#
Authorization to cancel a scheduled downgrade (signed by the payer only).
| Field | Type | Description |
|---|---|---|
subId | String | bytes32, subscription ID |
newSubId | String | bytes32, the target new subId of the downgrade to cancel (must equal the current schedule's newSubId) |
nonce | String | bytes32, anti-replay |
deadline | Long | Unix seconds |
signature | String | EIP-712 signature (65-byte hex) |
EIP-712 Signature Definitions#
Domain (domain separator)#
| Purpose | name | version | verifyingContract |
|---|---|---|---|
| terms / cancelAuth / pendingChangeCancelAuth | A2APaySubscription | 1 | Subscription contract |
| PermitSingle | Permit2 | (no version) | Permit2 contract |
Final digest: keccak256(0x1901 ‖ domainSeparator ‖ structHash).
TypeString#
SubscriptionTerms(address payer,address merchant,address facilitator,address token,uint160 amountPerPeriod,uint64 periodSec,uint32 maxPeriods,uint64 startAt,uint32 initialChargePeriods,uint160 initialChargeAmount,uint64 termsDeadline,bytes32 permitHash,bytes32 salt,uint8 planTier,bytes32 changeFromSubId,uint8 changeEffectiveAt,uint8 periodMode)
CancelAuth(uint8 action,bytes32 subId,uint8 initiator,bytes32 nonce,uint64 deadline)
PendingChangeCancelAuth(bytes32 subId,bytes32 newSubId,bytes32 nonce,uint64 deadline)
PermitSingle(PermitDetails details,address spender,uint256 sigDeadline)PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)Signature requirements:
-
All signatures are 65-byte secp256k1 (
r‖s‖v) with EIP-2 low-s enforced (s ≤ N/2, otherwise rejected withsignature_high_s); -
Before signing, you can eth_call the subscription contract's
hashSubscriptionTerms(terms)/hashPermitSingle(permit)view functions to cross-check your locally computed digests; -
Permit2 on-chain prerequisite: the Buyer must first grant a sufficient
approveto the Permit2 canonical contract0x000000000022d473030f116ddee9f6b43ac78ba3, otherwise creation is rejected.
Supported Networks and Tokens#
| Network | Chain Index | Status |
|---|---|---|
| X Layer | 196 | Supported |
Stablecoins supported on X Layer:
| Token | Contract address |
|---|---|
| USDC | 0x74b7f16337b8972027f6196a17a631ac6de26d22 |
| USDG | 0x4ae46a509f6b1d9056937ba4500cb143933d2dc8 |
| USD₮0 | 0x779ded0c9e1022225f8e0630b35a9b54be713736 |
Error Codes#
Error responses use the unified envelope {"code": "<code>", "msg": "<message>", "data": null}.
1. Authentication Errors (HTTP 401)#
| Code | Description |
|---|---|
| 50103 | Request header OK-ACCESS-KEY cannot be empty |
| 50104 | Request header OK-ACCESS-PASSPHRASE cannot be empty |
| 50105 | Request header OK-ACCESS-PASSPHRASE incorrect |
| 50106 | Request header OK-ACCESS-SIGN cannot be empty |
| 50107 | Request header OK-ACCESS-TIMESTAMP cannot be empty |
| 50111 | Invalid OK-ACCESS-KEY |
| 50112 | Invalid OK-ACCESS-TIMESTAMP |
| 50113 | Invalid signature |
2. Request Errors#
| Code | HTTP status | Description |
|---|---|---|
| 50011 | 429 | Requests too frequent; the endpoint's rate limit was exceeded |
3. Business Errors#
Business errors on subscription endpoints return HTTP 200, with code set to the business error code and msg carrying a machine-readable error identifier (some append : plus a human-readable note):
| Code | Meaning |
|---|---|
| 30001 | Parameter / business validation failure; see the msg error identifier (table below) |
| 8000 | Internal system error |
| 10051 | Compliance block (payer or merchant matched a risk address) |
| -1 | Uncategorized system error, please retry later |
4. msg Error Identifiers#
Common values (grouped by triggering endpoint):
General
| Identifier | Description |
|---|---|
unsupported_chain | chainIndex does not support subscriptions |
feature_disabled | Subscription feature disabled by rollout switch (blocks create / change only; charges on existing subscriptions are unaffected) |
contract_not_configured | No subscription contract configured for the chain |
facilitator_not_registered | No available signer for the facilitator address |
missing_required_terms_fields / invalid_address_format / invalid_bytes32 | Missing fields / format errors |
unauthorized_caller | API Key merchant does not match the subscription's creating merchant |
subscription_not_found / sub_not_found | Subscription does not exist |
system_error | Uncategorized exception |
Create / Change (terms and signature validation)
| Identifier | Description |
|---|---|
amount_per_period_invalid / period_sec_invalid / max_periods_invalid / plan_tier_invalid | Invalid numeric value |
period_mode_invalid | periodMode is not 0/1 |
period_sec_not_allowed | periodSec ≠ 0 in calendar-month mode |
start_at_in_past | Non-zero startAt earlier than now |
initial_charge_mismatch / initial_charge_periods_exceeds_max / initial_charge_exceeds_limit | Initial-charge parameters out of bounds (must be 0 for downgrades; pre-start upfront fee out of bounds) |
token_mismatch | terms.token ≠ permit.details.token |
permit_spender_mismatch | permit.spender ≠ subscription contract |
permit_hash_mismatch | terms.permitHash ≠ actual permit struct hash |
allowance_insufficient / allowance_expired | Permit allowance / validity insufficient to cover the commitment |
terms_deadline_expired / permit_sig_deadline_expired | Signature expired |
terms_signature_invalid / terms_binding_invalid / permit_signature_invalid | Recovered signer ≠ payer |
signature_high_s / signature_recovery_failed | Malformed signature |
salt_already_used / subscription_already_exists | Anti-replay rejection |
create_must_have_zero_changeFromSubId / create_must_have_none_changeEffectiveAt | Create must not carry change fields |
Change-specific
| Identifier | Description |
|---|---|
change_must_have_nonzero_changeFromSubId / change_must_have_non_none_effectiveAt | Change must carry change fields |
changeFromSubId_mismatch / payer_mismatch / merchant_mismatch / facilitator_mismatch / period_sec_mismatch / period_mode_mismatch | Old/new subscription invariant violated |
tier_same / change_effective_at_mismatch | Tier and effectiveness direction do not match |
start_at_mismatch | startAt violates the change rules |
sub_not_active_for_change / pending_change_exists | Old subscription not active / a pending downgrade already exists |
change_in_flight | Another change for the same subscription is in flight |
Charge
| Identifier | Description |
|---|---|
subscription_not_active | Subscription not active |
all_periods_charged | All periods already charged |
period_not_due | Current period not yet due |
charge_in_flight | Another charge for the same subscription is in flight |
insufficient_allowance / insufficient_balance / permit_expired | Insufficient allowance / balance / expired permit |
Cancel / Cancel-pending-change / Finalize
| Identifier | Description |
|---|---|
cancel_auth_required / cancel_subId_mismatch / cancel_deadline_expired / cancel_signature_invalid | CancelAuth validation failure |
no_pending_change_or_not_pending | No pending downgrade |
pending_cancel_subId_mismatch / pending_cancel_target_mismatch / pending_cancel_deadline_expired / pending_cancel_signature_invalid | Cancel-pending-change authorization validation failure |
not_ended | Service window not ended yet (finalize-expired) |
On-chain
| Identifier | Description |
|---|---|
on_chain_simulation_failed | Pre-execution simulation reverted |
on_chain_tx_failed | On-chain transaction failed |
intent_submit_failed | Transaction submission failed |
- Subscription Model OverviewAuthenticationCommon ConventionssyncSettle \(all write endpoints\)Field RepresentationEnumsPeriod Mode \(periodMode\)1. /api/v6/pay/x402/supportedRequest ExampleResponse Example2. /api/v6/pay/x402/subscriptionsRequest ParametersResponse ParametersRequest Example \(calendar\-month billing\)Response Example3. /api/v6/pay/x402/subscriptions/chargeRequest ParametersResponse ParametersRequest ExampleResponse Example — Regular ChargeResponse Example — Downgrade Switch Triggered This Period4. /api/v6/pay/x402/subscriptions/changeRequest ParametersResponse ParametersRequest Example — Upgrade \(immediate\)Response Example — UpgradeResponse Example — Downgrade \(scheduled to period end\)5. /api/v6/pay/x402/subscriptions/cancelRequest ParametersResponse ParametersRequest ExampleResponse Example6. /api/v6/pay/x402/subscriptions/cancel\-pending\-changeRequest ParametersResponse ParametersRequest ExampleResponse Example7. /api/v6/pay/x402/subscriptions/finalize\-expiredRequest ParametersResponse ParametersRequest ExampleResponse Example8. /api/v6/pay/x402/subscriptions/detailRequest ParametersResponse ParametersRequest ExampleResponse Example9. /api/v6/pay/x402/subscriptions/chargesRequest ParametersResponse ParametersRequest ExampleResponse Example10. /api/v6/pay/x402/subscriptions/pendingRequest ParametersResponse ParametersRequest ExampleResponse Example — Schedule ExistsResponse Example — No Schedule11. /api/v6/pay/x402/buyers/\{buyer\}/allowance\-statusRequest ParametersResponse ParametersRequest ExampleResponse Example12. /api/v6/pay/x402/buyers/\{buyer\}/subscriptionsRequest ParametersResponse ParametersRequest ExampleResponse ExampleCommon Data StructuresSubscriptionTermsPermitSingleCancelAuthPendingChangeCancelAuthEIP\-712 Signature DefinitionsDomain \(domain separator\)TypeStringSupported Networks and TokensError Codes1. Authentication Errors \(HTTP 401\)2. Request Errors3. Business Errors4. msg Error Identifiers
