Skip to main content

Refunds & Revocations

The Refunds & Revocations capability is the canonical refund endpoint. When a payment is refunded, the protocol can automatically revoke the associated access tokens, signed URLs, sessions, or license keys — inspired by Stripe refunds, crypto token burning, and DRM-style license revocation patterns.


Swimlane: Full Refund with Automatic Revocation

This swimlane shows the full lifecycle of a refund request that includes automatic token revocation. The Agent's AI handles the conversation with the human payer before calling the ItPay Protocol refund API.

┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────┐
│ Human │ │ Agent │ │ ItPay │ │ Channel │
│ (Payer) │ │ (Buyer) │ │ Protocol │ │ (Alipay) │
└────┬─────┘ └──────┬───────┘ └────┬─────┘ └────┬─────┘
│ │ │ │
│ "I want my money │ │ │
│ back for the │ │ │
│ Pro plan I │ │ │
│ bought" │ │ │
│─────────────────>│ │ │
│ │ │ │
│ │ "Sure, I'll │ │
│ │ process a full │ │
│ │ refund and │ │
│ │ revoke access │ │
│ │ — confirm?" │ │
│<─────────────────│ │ │
│ │ │ │
│ "Yes, go ahead" │ │ │
│─────────────────>│ │ │
│ │ │ │
│ │ POST /v1/refunds │
│ │ { │
│ │ "payment_intent": │
│ │ "pi_01J7XZ1A2B3C4D5...", │
│ │ "reason": │
│ │ "customer_request", │
│ │ "revoke": { │
│ │ "targets": [ │
│ │ {type:"access_token",...},│
│ │ {type:"signed_url",...} │
│ │ ] │
│ │ } │
│ │ } │
│ │─────────────────>│ │
│ │ │ │
│ │ │ Validate │
│ │ │ refund req │
│ │ │ Check window │
│ │ │ (≤365d) │
│ │ │ Verify auth │
│ │ │ │
│ │ │ POST refund │
│ │ │ to channel │
│ │ │──────────────>│
│ │ │ │
│ │ │ │ Process
│ │ │ │ refund
│ │ │ │ (¥6.99)
│ │ │ │
│ │ │ Channel │
│ │ │ success │
│ │ │<──────────────│
│ │ │ │
│ │ │ Record refund│
│ │ │ Initiate │
│ │ │ revocation │
│ │ │ │
│ │ │ Revoke │
│ │ │ access_token │
│ │ │ at_01J7XZ... │
│ │ │ (status: │
│ │ │ revoked) │
│ │ │ │
│ │ │ Revoke │
│ │ │ signed_url │
│ │ │ url_01J7Y0...│
│ │ │ (status: │
│ │ │ revoked) │
│ │ │ │
│ │ 201 Created │ │
│ │ { │
│ │ "id":"ref_01J7Z0A1B2C3...", │
│ │ "status":"succeeded", │
│ │ "amount":{value:699,...}, │
│ │ "revocations":[ │
│ │ {target_type:"access_token",│
│ │ status:"revoked"}, │
│ │ {target_type:"signed_url", │
│ │ status:"revoked"} │
│ │ ] │
│ │ } │
│ │<─────────────────│ │
│ │ │ │
│ │ Send webhook │ │
│ │ revocation. │ │
│ │ succeeded │ │
│ │ { │
│ │ "event": │
│ │ "revocation.succeeded", │
│ │ "data":{ │
│ │ "refund_id":..., │
│ │ "target_id":..., │
│ │ "status":"revoked" │
│ │ } │
│ │ } │
│ │<─────────────────│ │
│ │ │ │
│ │ Send webhook │ │
│ │ revocation. │ │
│ │ batch.completed │ │
│ │<─────────────────│ │
│ │ │ │
│ │ "Done! ¥6.99 │ │
│ │ refunded to │ │
│ │ your original │ │
│ │ payment method. │ │
│ │ Access tokens │ │
│ │ have been │ │
│ │ revoked. You │ │
│ │ will receive a │ │
│ │ notification." │ │
│<─────────────────│ │ │
│ │ │ │

Swimlane: Partial Refund with Scoped Revocation

This swimlane shows a partial refund (¥2.00 of a ¥6.99 payment) that revokes access only within a specific scope (read:summary).

┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────┐
│ Human │ │ Agent │ │ ItPay │ │ Channel │
│ (Payer) │ │ (Buyer) │ │ Protocol │ │ (Alipay) │
└────┬─────┘ └──────┬───────┘ └────┬─────┘ └────┬─────┘
│ │ │ │
│ "I only want a │ │ │
│ partial refund │ │ │
│ for the summary │ │ │
│ report — keep │ │ │
│ the rest" │ │ │
│─────────────────>│ │ │
│ │ │ │
│ │ "A partial │ │
│ │ refund of ¥2.00 │ │
│ │ will revoke │ │
│ │ summary access │ │
│ │ only. Confirm?" │ │
│<─────────────────│ │ │
│ │ │ │
│ "Confirm" │ │ │
│─────────────────>│ │ │
│ │ │ │
│ │ POST /v1/refunds │
│ │ { │
│ │ "payment_intent": │
│ │ "pi_01J7XZ1A2B3C4D5...", │
│ │ "amount":{ │
│ │ "value":200, │
│ │ "currency":"CNY" │
│ │ }, │
│ │ "reason":"partial_refund", │
│ │ "revoke":{"targets":[{ │
│ │ type:"signed_url", │
│ │ id:"url_report_partial_q2", │
│ │ scope:"read:summary" │
│ │ }]} │
│ │ } │
│ │─────────────────>│ │
│ │ │ │
│ │ │ Calculate │
│ │ │ ratio = 28.6%│
│ │ │ Map to │
│ │ │ "read:detail"│
│ │ │ (default) │
│ │ │ But override │
│ │ │ to explicit │
│ │ │ "read:summary│
│ │ │ │
│ │ │ POST partial │
│ │ │ refund ¥2.00 │
│ │ │──────────────>│
│ │ │ │
│ │ │ │ Partial
│ │ │ │ refund
│ │ │ │ accepted
│ │ │<──────────────│
│ │ │ │
│ │ │ Revoke │
│ │ │ signed_url │
│ │ │ scope: │
│ │ │ read:summary │
│ │ │ (status: │
│ │ │ revoked) │
│ │ │ │
│ │ 201 Created │ │
│ │ { │
│ │ "status":"succeeded", │
│ │ "amount":{value:200,...}, │
│ │ "remaining_refundable": │
│ │ {value:499,...}, │
│ │ "revocations":[{ │
│ │ "target_type":"signed_url", │
│ │ "scope":"read:summary", │
│ │ "status":"revoked" │
│ │ }] │
│ │ } │
│ │<─────────────────│ │
│ │ │ │
│ │ "Refunded ¥2.00. │ │
│ │ Your summary │ │
│ │ report access │ │
│ │ has been │ │
│ │ revoked. The │ │
│ │ remaining ¥4.99 │ │
│ │ is still │ │
│ │ refundable." │ │
│<─────────────────│ │ │
│ │ │ │

Swimlane: Refund Window Expired / Channel Rejects

This swimlane shows the error paths — when the refund window has expired (past the channel's time limit) or when the channel rejects the refund for another reason (e.g., max partial refunds reached).

┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────┐
│ Human │ │ Agent │ │ ItPay │ │ Channel │
│ (Payer) │ │ (Buyer) │ │ Protocol │ │ (Alipay) │
└────┬─────┘ └──────┬───────┘ └────┬─────┘ └────┬─────┘
│ │ │ │
│ ◆ ERROR PATH 1: │ │ │
│ Refund Window │ │ │
│ Expired (≥365d) │ │ │
│ │ │ │
│ "I'd like to │ │ │
│ refund a │ │ │
│ payment from │ │ │
│ 14 months ago" │ │ │
│─────────────────>│ │ │
│ │ │ │
│ │ POST /v1/refunds │
│ │ {payment_intent:..., │
│ │ amount:{...}} │
│ │─────────────────>│ │
│ │ │ │
│ │ │ Check refund │
│ │ │ window: │
│ │ │ 400d > 365d │
│ │ │ (Alipay max) │
│ │ │ │
│ │ 400 Bad Request │ │
│ │ { │
│ │ "error":{ │
│ │ "code": │
│ │ "REFUND_WINDOW_EXPIRED", │
│ │ "message": │
│ │ "Refund window has expired...",│
│ │ "details":{ │
│ │ "max_window_days":365, │
│ │ "payment_age_days":400, │
│ │ "channel":"alipay" │
│ │ } │
│ │ } │
│ │<─────────────────│ │
│ │ │ │
│ │ "Sorry, this │ │
│ │ payment is too │ │
│ │ old to refund. │ │
│ │ Alipay allows │ │
│ │ refunds only │ │
│ │ within 365 days │ │
│ │ of purchase." │ │
│<─────────────────│ │ │
│ │ │ │
┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────┐
│ Human │ │ Agent │ │ ItPay │ │ Channel │
│ (Payer) │ │ (Buyer) │ │ Protocol │ │ (Alipay) │
└────┬─────┘ └──────┬───────┘ └────┬─────┘ └────┬─────┘
│ │ │ │
│ ◆ ERROR PATH 2: │ │ │
│ Channel Rejected │ │ │
│ (max partials) │ │ │
│ │ │ │
│ "I need another │ │ │
│ partial refund" │ │ │
│─────────────────>│ │ │
│ │ │ │
│ │ POST /v1/refunds │
│ │ {payment_intent:..., │
│ │ amount:{value:50,...}, │
│ │ reason:"partial_refund"} │
│ │─────────────────>│ │
│ │ │ │
│ │ │ Window OK │
│ │ │ Route to │
│ │ │ WeChat Pay │
│ │ │ │
│ │ │ POST refund │
│ │ │──────────────>│
│ │ │ │
│ │ │ │ Reject —
│ │ │ │ max 50
│ │ │ │ partials
│ │ │ │ reached
│ │ │<──────────────│
│ │ │ │
│ │ 409 Conflict │ │
│ │ { │
│ │ "error":{ │
│ │ "code": │
│ │ "REFUND_CHANNEL_REJECTED", │
│ │ "message": │
│ │ "The payment channel rejected │
│ │ the refund", │
│ │ "details":{ │
│ │ "channel":"wechat_pay", │
│ │ "channel_reason": │
│ │ "Max partial refund count...", │
│ │ "max_partial_count":50, │
│ │ "current_partial_count":50 │
│ │ } │
│ │ } │
│ │<─────────────────│ │
│ │ │ │
│ │ "This payment │ │
│ │ has already had │ │
│ │ 50 partial │ │
│ │ refunds, which │ │
│ │ is the maximum │ │
│ │ WeChat allows. │ │
│ │ Would you like │ │
│ │ a full refund │ │
│ │ instead?" │ │
│<─────────────────│ │ │
│ │ │ │

Interaction Trace: Refund Creation & Status Polling

Refund Creation (Full Refund)

Request:

POST /v1/refunds
Content-Type: application/json
Authorization: Bearer ***
Idempotency-Key: idemp_01J7Z0A1B2C3D4E5F6G7H8I9J

{
"payment_intent": "pi_01J7XZ1A2B3C4D5E6F7G8H9IK",
"amount": {
"value": 699,
"currency": "CNY"
},
"reason": "customer_request",
"revoke": {
"targets": [
{
"type": "access_token",
"id": "at_01J7XZ9K8J7H6G5F4E3D2C1B0A"
},
{
"type": "signed_url",
"id": "url_01J7Y0B2A1C3D4E5F6G7H8I9J"
}
],
"auto_revoke": true,
"webhook_notify": true
}
}

Response (201 Created):

HTTP/1.1 201 Created
Content-Type: application/json
Idempotency-Key: idemp_01J7Z0A1B2C3D4E5F6G7H8I9J

{
"id": "ref_01J7Z0A1B2C3D4E5F6G7H8I9J",
"payment_intent": "pi_01J7XZ1A2B3C4D5E6F7G8H9IK",
"amount": {
"value": 699,
"currency": "CNY"
},
"status": "succeeded",
"reason": "customer_request",
"revocations": [
{
"target_type": "access_token",
"target_id": "at_01J7XZ9K8J7H6G5F4E3D2C1B0A",
"status": "revoked",
"revoked_at": "2026-05-27T09:32:00Z"
},
{
"target_type": "signed_url",
"target_id": "url_01J7Y0B2A1C3D4E5F6G7H8I9J",
"status": "revoked",
"revoked_at": "2026-05-27T09:32:01Z"
}
],
"created_at": "2026-05-27T09:30:00Z",
"updated_at": "2026-05-27T09:32:01Z"
}

Status Polling (For Async Refunds)

Refunds may be processed asynchronously — especially when the channel requires confirmation. Poll the refund status with GET /v1/refunds/:id.

Request:

GET /v1/refunds/ref_01J7Z0A1B2C3D4E5F6G7H8I9J
Authorization: Bearer ***

Response (Pending — still processing):

HTTP/1.1 200 OK
Content-Type: application/json

{
"id": "ref_01J7Z0A1B2C3D4E5F6G7H8I9J",
"status": "pending",
"amount": {
"value": 699,
"currency": "CNY"
},
"revocations": [
{
"target_type": "access_token",
"target_id": "at_01J7XZ9K8J7H6G5F4E3D2C1B0A",
"status": "revoked"
},
{
"target_type": "signed_url",
"target_id": "url_01J7Y0B2A1C3D4E5F6G7H8I9J",
"status": "pending"
}
],
"created_at": "2026-05-27T09:30:00Z",
"updated_at": "2026-05-27T09:31:00Z"
}

Response (Final — succeeded with all revocations):

HTTP/1.1 200 OK
Content-Type: application/json

{
"id": "ref_01J7Z0A1B2C3D4E5F6G7H8I9J",
"status": "succeeded",
"amount": {
"value": 699,
"currency": "CNY"
},
"revocations": [
{
"target_type": "access_token",
"target_id": "at_01J7XZ9K8J7H6G5F4E3D2C1B0A",
"status": "revoked",
"revoked_at": "2026-05-27T09:32:00Z"
},
{
"target_type": "signed_url",
"target_id": "url_01J7Y0B2A1C3D4E5F6G7H8I9J",
"status": "revoked",
"revoked_at": "2026-05-27T09:32:01Z"
}
],
"revocation_batch_status": "completed",
"created_at": "2026-05-27T09:30:00Z",
"updated_at": "2026-05-27T09:32:01Z"
}

Revocation Webhook Payload

revocation.succeeded webhook (sent per target):

{
"event": "revocation.succeeded",
"data": {
"refund_id": "ref_01J7Z0A1B2C3D4E5F6G7H8I9J",
"target_type": "access_token",
"target_id": "at_01J7XZ9K8J7H6G5F4E3D2C1B0A",
"status": "revoked",
"revoked_at": "2026-05-27T09:32:00Z"
}
}

revocation.failed webhook (if a target cannot be revoked):

{
"event": "revocation.failed",
"data": {
"refund_id": "ref_01J7Z0A1B2C3D4E5F6G7H8I9J",
"target_type": "signed_url",
"target_id": "url_01J7Y0B2A1C3D4E5F6G7H8I9J",
"error": {
"code": "revocation_target_not_found",
"message": "The specified target does not exist or is already revoked"
}
}
}

revocation.batch.completed webhook (sent after all targets processed):

{
"event": "revocation.batch.completed",
"data": {
"refund_id": "ref_01J7Z0A1B2C3D4E5F6G7H8I9J",
"total_targets": 2,
"revoked": 1,
"failed": 1,
"completed_at": "2026-05-27T09:32:05Z"
}
}

Refund API (Stripe-Compatible)

The refund endpoint follows the standard Stripe refund contract with additional revocation fields.

Endpoint

MethodEndpointDescription
POST/v1/refundsProcess a refund and optionally revoke access

Request Fields

FieldTypeRequiredDescription
payment_intentstringyesThe PaymentIntent id to refund
amountMoneynoPartial refund amount. If omitted, a full refund is processed.
reasonstringnoBrief reason for the refund (max 256 chars)
revokeRevocationSpecnoAutomatic revocation instructions (see below)
descriptionstringnoDetailed explanation (max 1,024 chars)

RevocationSpec Object

FieldTypeRequiredDescription
targetsRevocationTarget[]yesList of access items to revoke
auto_revokebooleannoIf true, revocation is automatic on refund. Default: true.
webhook_notifybooleannoIf true, send revocation webhooks. Default: true.

Revocation Targets

Target TypeDescriptionExample
access_tokenOAuth2 / Bearer token"at_01J7XZ..."
signed_urlTime-limited download URL"https://cdn.example.com/file.pdf?token=***
sessionUser/agent session"sess_01J7Y1..."
license_keyProduct license key"LIC-XXXX-YYYY-ZZZZ"

RevocationTarget Object

FieldTypeDescription
typestringOne of: access_token, signed_url, session, license_key
idstringIdentifier of the target to revoke
scopestringOptional scope filter (e.g., "read:reports", "all")

Request Examples

Full Refund with Automatic Revocation

POST /v1/refunds
Content-Type: application/json
Authorization: Bearer ***

{
"payment_intent": "pi_01J7XZ1A2B3C4D5E6F7G8H9IK",
"reason": "customer_request",
"revoke": {
"targets": [
{
"type": "access_token",
"id": "at_01J7XZ9K8J7H6G5F4E3D2C1B0A"
},
{
"type": "signed_url",
"id": "url_01J7Y0B2A1C3D4E5F6G7H8I9J"
}
],
"auto_revoke": true,
"webhook_notify": true
}
}

Response Example

HTTP/1.1 201 Created
Content-Type: application/json

{
"id": "ref_01J7Z0A1B2C3D4E5F6G7H8I9J",
"payment_intent": "pi_01J7XZ1A2B3C4D5E6F7G8H9IK",
"amount": {
"value": 699,
"currency": "CNY"
},
"status": "succeeded",
"reason": "customer_request",
"revocations": [
{
"target_type": "access_token",
"target_id": "at_01J7XZ9K8J7H6G5F4E3D2C1B0A",
"status": "revoked",
"revoked_at": "2026-05-27T09:32:00Z"
},
{
"target_type": "signed_url",
"target_id": "url_01J7Y0B2A1C3D4E5F6G7H8I9J",
"status": "revoked",
"revoked_at": "2026-05-27T09:32:01Z"
}
],
"created_at": "2026-05-27T09:30:00Z",
"updated_at": "2026-05-27T09:32:01Z"
}

Partial Refund with Partial Revocation

POST /v1/refunds
Content-Type: application/json
Authorization: Bearer ***

{
"payment_intent": "pi_01J7XZ1A2B3C4D5E6F7G8H9IK",
"amount": {
"value": 200,
"currency": "CNY"
},
"reason": "partial_refund",
"revoke": {
"targets": [
{
"type": "signed_url",
"id": "url_report_partial_q2",
"scope": "read:summary"
}
],
"auto_revoke": true
}
}

Response Example

HTTP/1.1 201 Created
Content-Type: application/json

{
"id": "ref_01J7Z0B2C3D4E5F6G7H8I9J0K",
"payment_intent": "pi_01J7XZ1A2B3C4D5E6F7G8H9IK",
"amount": {
"value": 200,
"currency": "CNY"
},
"status": "succeeded",
"reason": "partial_refund",
"remaining_refundable": {
"value": 499,
"currency": "CNY"
},
"revocations": [
{
"target_type": "signed_url",
"target_id": "url_report_partial_q2",
"scope": "read:summary",
"status": "revoked",
"revoked_at": "2026-05-27T09:35:00Z"
}
],
"created_at": "2026-05-27T09:30:00Z",
"updated_at": "2026-05-27T09:35:00Z"
}

Partial Refund + Partial Revocation Mapping

When a partial refund is processed, the revocation scope maps proportionally:

Refund PercentageRevocation Behavior
1% – 25%Revoke scope: "read:summary" only
26% – 50%Revoke scope: "read:detail"
51% – 75%Revoke scope: "read:full"
76% – 100%Revoke scope: "all" (full access)

Merchants can override this mapping by providing an explicit scope in the RevocationTarget.

Proportional Mapping Logic

refund_ratio = refund_amount / original_payment_amount

if refund_ratio <= 0.25:
scope = "read:summary"
elif refund_ratio <= 0.50:
scope = "read:detail"
elif refund_ratio <= 0.75:
scope = "read:full"
else:
scope = "all"

Revocation State Machine

┌──────────────┐
│ pending │
└──────┬───────┘

┌────┴────┐
│ │
▼ ▼
┌────────┐ ┌─────────┐
│revoked │ │ failed │
└───┬────┘ └─────────┘


┌────────┐
│expired │
└────────┘

Transitions

FromToTrigger
pendingrevokedRevocation executed successfully
pendingfailedRevocation failed (target not found, permission error)
revokedexpiredRevocation has been in effect longer than the configured retention period

Configurable Auto-Revocation

Merchants can set default revocation behavior per service manifest or override per refund request.

Per-Service Manifest Default

{
"id": "01J7XYKZ1A2B3C4D5E6F7G8H9I",
"revocation_policy": {
"auto_revoke_on_refund": true,
"default_targets": [
{
"type": "access_token",
"scope": "all"
},
{
"type": "session",
"scope": "all"
}
],
"webhook_on_revoke": true
}
}

Override in Refund Request

Set revoke.auto_revoke: false in the refund request to skip revocation for a specific refund. This is useful for partial refunds where the merchant wants to maintain good-faith access.


Revocation Webhook Events

EventDescription
revocation.succeededA revocation target was successfully revoked
revocation.failedRevocation failed for a target
revocation.batch.completedAll revocation targets for a refund have been processed

Revocation Succeeded Payload

{
"event": "revocation.succeeded",
"data": {
"refund_id": "ref_01J7Z0A1B2C3D4E5F6G7H8I9J",
"target_type": "access_token",
"target_id": "at_01J7XZ9K8J7H6G5F4E3D2C1B0A",
"status": "revoked",
"revoked_at": "2026-05-27T09:32:00Z"
}
}

Key Behaviors

  • Atomic refund + revocation: Refund and revocation are processed in the same transaction. If revocation fails, the refund is still completed but the merchant receives a revocation.failed webhook to handle manually.
  • Stripe-compatible refunds: The refund API is a superset of Stripe's refunds — standard Stripe clients can pass payment_intent and amount without the revoke field and get standard refund behavior.
  • Partial refund + scoped revocation: The protocol maps refund percentage to access scope by default, but merchants can override with explicit scope per target.
  • Revocation is irreversible: Once a target is revoked, it cannot be restored. The merchant must issue a new token or URL if re-access is needed.
  • Retry on failure: Failed revocations are retried up to 3 times with exponential backoff before being marked as permanently failed.
  • Crypto token burning equivalence: Access token revocation is semantically equivalent to burning a token on-chain — the token is removed from the active registry and cannot be used for future authentication.

Channel-Specific Refund Constraints

Refunds are routed through the original payment channel. Each channel imposes its own constraints:

ConstraintWeChat Pay (APIv3)AlipayPromptPay / SEA
Partial refundsMax 50 per orderUnlimitedVaries by bank
Refund window365 days from payment90–365 days depending on industryVaries by bank
Rate limit150 QPS success rateStandard API rate limitBank-dependent
Fee treatmentProportional refund of feeProportional refundVaries
Refund methodPOST /v3/refund/domestic/refundsalipay.trade.refundBank-specific
Async confirmationYes (callback)Yes (async notification)Yes

If a refund fails due to channel constraints (e.g., refund window expired, max partial refunds reached), the API returns REFUND_CHANNEL_REJECTED with details from the downstream provider.

Error CodeDescription
REFUND_CHANNEL_REJECTEDThe payment channel rejected the refund (see details.channel_reason for provider-specific message)
REFUND_WINDOW_EXPIREDThe refund window for this channel has passed
REFUND_LIMIT_EXCEEDEDChannel-specific limit reached (e.g., max 50 partial refunds for WeChat)

Error Codes

CodeDescription
revocation_target_not_foundThe specified target does not exist or is already revoked
revocation_target_invalid_typeThe target type is not recognized
revocation_scope_invalidThe specified scope is not valid for the target type
revocation_limit_exceededToo many revocation targets in a single request (max 100)
refund_exceeds_revocablePartial refund amount exceeds the remaining revocable balance

Next Steps