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
| Method | Endpoint | Description |
|---|---|---|
POST | /v1/refunds | Process a refund and optionally revoke access |
Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
payment_intent | string | yes | The PaymentIntent id to refund |
amount | Money | no | Partial refund amount. If omitted, a full refund is processed. |
reason | string | no | Brief reason for the refund (max 256 chars) |
revoke | RevocationSpec | no | Automatic revocation instructions (see below) |
description | string | no | Detailed explanation (max 1,024 chars) |
RevocationSpec Object
| Field | Type | Required | Description |
|---|---|---|---|
targets | RevocationTarget[] | yes | List of access items to revoke |
auto_revoke | boolean | no | If true, revocation is automatic on refund. Default: true. |
webhook_notify | boolean | no | If true, send revocation webhooks. Default: true. |
Revocation Targets
| Target Type | Description | Example |
|---|---|---|
access_token | OAuth2 / Bearer token | "at_01J7XZ..." |
signed_url | Time-limited download URL | "https://cdn.example.com/file.pdf?token=*** |
session | User/agent session | "sess_01J7Y1..." |
license_key | Product license key | "LIC-XXXX-YYYY-ZZZZ" |
RevocationTarget Object
| Field | Type | Description |
|---|---|---|
type | string | One of: access_token, signed_url, session, license_key |
id | string | Identifier of the target to revoke |
scope | string | Optional 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 Percentage | Revocation 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
| From | To | Trigger |
|---|---|---|
pending | revoked | Revocation executed successfully |
pending | failed | Revocation failed (target not found, permission error) |
revoked | expired | Revocation 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
| Event | Description |
|---|---|
revocation.succeeded | A revocation target was successfully revoked |
revocation.failed | Revocation failed for a target |
revocation.batch.completed | All 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.failedwebhook to handle manually. - Stripe-compatible refunds: The refund API is a superset of Stripe's refunds — standard Stripe clients can pass
payment_intentandamountwithout therevokefield 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:
| Constraint | WeChat Pay (APIv3) | Alipay | PromptPay / SEA |
|---|---|---|---|
| Partial refunds | Max 50 per order | Unlimited | Varies by bank |
| Refund window | 365 days from payment | 90–365 days depending on industry | Varies by bank |
| Rate limit | 150 QPS success rate | Standard API rate limit | Bank-dependent |
| Fee treatment | Proportional refund of fee | Proportional refund | Varies |
| Refund method | POST /v3/refund/domestic/refunds | alipay.trade.refund | Bank-specific |
| Async confirmation | Yes (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 Code | Description |
|---|---|
REFUND_CHANNEL_REJECTED | The payment channel rejected the refund (see details.channel_reason for provider-specific message) |
REFUND_WINDOW_EXPIRED | The refund window for this channel has passed |
REFUND_LIMIT_EXCEEDED | Channel-specific limit reached (e.g., max 50 partial refunds for WeChat) |
Error Codes
| Code | Description |
|---|---|
revocation_target_not_found | The specified target does not exist or is already revoked |
revocation_target_invalid_type | The target type is not recognized |
revocation_scope_invalid | The specified scope is not valid for the target type |
revocation_limit_exceeded | Too many revocation targets in a single request (max 100) |
refund_exceeds_revocable | Partial refund amount exceeds the remaining revocable balance |
Next Steps
- Escrow Payments — Trust-minimized payment flow with dispute resolution
- Webhook Encryption — Secure webhook delivery with HMAC signatures and E2E encryption