# 商户 Webhook

- 服务地址（示例，真实 Host 由接入团队提供）：`https://{merchantWebhookHost}`
- 认证：Ed25519 签名
- 版本：1.1.0
- OpenAPI 规范：https://docs.potabank.com/openapi/merchant-webhook.openapi.yaml

> 本页是 PSP 推送事件的**接口契约**。要实现接收端，请先阅读 [Webhook 对接指南](/guide/webhooks)（验签、重试、幂等、回调地址要求）和 [Webhook 事件目录](/guide/webhook-events)（每个事件的触发条件与载荷示例）。

Canonical outbound contract for PSP merchant events delivered to
merchant-owned HTTPS endpoints. The request body is one of five envelopes;
route on the `X-Webhook-Event` header first.

- `ClassifiedMerchantWebhookEnvelope`: payment intent, transfer and credit
  card lifecycle events. `data` always carries classification schema
  version 1 and the resolved public payment classification.
- `UnclassifiedMerchantWebhookEnvelope`: refund and account freeze
  lifecycle events. `data` cannot carry classification fields.
- `MerchantSettlementWebhookEnvelope`: `merchant_settlement.*.v1` events
  for settlement items. Different top-level fields; amounts are decimal
  strings.
- `WithdrawalStatusWebhookEnvelope` and `SettlementStatusWebhookEnvelope`:
  flat status bodies sent by the withdrawal and settlement workflows.

Amounts are integers in the currency's minor unit unless a schema states
otherwise. Fields documented as aliases always carry the same value.
Only the `data` objects of `ClassifiedMerchantWebhookEnvelope` and
`UnclassifiedMerchantWebhookEnvelope` stay open
(`additionalProperties: true`) so PSP can add fields without a new
contract version. `MerchantSettlementWebhookEnvelope`, its
`MerchantSettlementEventData`, both flat status envelopes, the classified
and unclassified envelopes themselves, `InvoicePaymentPolicy` and
`ResolvedPaymentClassification` reject undeclared fields. Retries and
operator redeliveries resend the stored body unchanged, so fields
introduced later may be absent from older events. A callback regenerated
by an operator refresh is stored and resent the same way; bodies stored
before a refresh rebuilt the complete event can carry only a subset of the
fields documented as required for first deliveries (for example
`classification` on payment events), and may omit fields that are optional
for first deliveries as well (for example the refund `fee_amount` or
`original_intent_type`). Stable taxonomy IDs,
direction, provenance, aliases,
defaults, and legacy flat classification fields are intentionally not
exposed.

## 事件

### Deliver a merchant transaction event

**EVENT** `merchantEvent`

PSP signs the exact request body as `<X-Timestamp>.<raw-body>` with the
account-level Ed25519 signing key identified by `X-Webhook-Key-ID`.
Verify the signature and timestamp window before parsing the body, and
deduplicate by `X-Webhook-Event-ID`.

**请求头**

| 名称 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `X-Timestamp` | `string` | 必填 | Unix time in seconds (UTC) at which PSP signed this HTTP attempt. It is regenerated on every attempt (automatic retry, merchant manual retry and operator redelivery), so it is neither the event time nor a deduplication key. Reject requests whose value differs from the receiver's clock by more than 300 seconds. · 格式 `^[0-9]+$` |
| `X-Signature` | `string` | 必填 | Standard Base64 encoding of the 64-byte Ed25519 signature over the ASCII bytes `<X-Timestamp>.<raw request body>`. It is recomputed on every attempt because `X-Timestamp` changes. Verify against the raw body bytes, never a re-serialized JSON document. · 最短 1 |
| `X-Signature-Algorithm` | `string` | 必填 | Signature algorithm; always `ed25519`. · 固定值 `ed25519` |
| `X-Webhook-Key-ID` | `string` | 必填 | Identifier of the account-level signing key bound to this delivery. All attempts of a delivery, including operator redeliveries of it, are signed with the originally bound key. Select the public key by this ID and keep retired keys available during rotation. · 最短 1 |
| `X-Webhook-Version` | `string` | 必填 | Version of the delivery header and signing contract, fixed to `2026-06-11`. It does not version the body; merchant settlement bodies carry their own `schema_version`. · 固定值 `2026-06-11` |
| `X-Webhook-ID` | `string<uuid>` | 必填 | Delivery ID. It stays the same across automatic retries and merchant manual retries of one delivery. An operator redelivery creates a new delivery with a new `X-Webhook-ID` while keeping the same `X-Webhook-Event-ID` and body. Use it to correlate attempts with PSP delivery records, not for business deduplication. |
| `X-Webhook-Event` | `string` | 必填 | Event type of the body: the body `event` for event/data envelopes, or the body `event_type` for merchant settlement, withdrawal and settlement status bodies. Route on this header before parsing the body; record and acknowledge event types you do not handle. · 最短 1 |
| `X-Webhook-Event-ID` | `string<uuid>` | 必填 | Event ID. It never changes across attempts, manual retries or operator redeliveries of the same event and is the recommended deduplication key. For merchant settlement events it equals the body `event_id`; for other events it is a stable PSP-derived ID that is not repeated in the body. |
| `X-Merchant-Account-ID` | `string<uuid>` | 必填 | Merchant account the delivery belongs to; identical on every attempt. Use it to scope events whose `data` has no account ID, such as refund completion events. |

**请求体** (application/json · 必填)

*变体 ClassifiedMerchantWebhookEnvelope*

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `event` | `enum` | 必填 | 可选值：`payment.intent.completed`、`payment.intent.partially_paid`、`payment.intent.expired`、`payment.intent.canceled`、`payment.transfer.completed`、`payment.transfer.failed`、`payment.transfer.returned`、`creditcard_invoice.captured`、`creditcard_invoice.3ds_timeout` |
| `timestamp` | `string<date-time>` | 必填 | Time the event occurred (RFC 3339, may include fractional seconds and an offset). |
| `data` | `object` | 必填 | — |
| `data.classification_schema_version` | `integer` | 必填 | 固定值 `1` |
| `data.classification` | `object` | 必填 | — |
| `data.classification.classification_status` | `string` | 必填 | 固定值 `resolved` |
| `data.classification.payment_category` | `string` | 必填 | 格式 `^[a-z0-9]+(?:_[a-z0-9]+)*$` |
| `data.classification.payment_method` | `string` | 必填 | 格式 `^[a-z0-9]+(?:_[a-z0-9]+)*$` |
| `data.classification.rail_variant` | `string` | 必填 | 格式 `^[a-z0-9]+(?:_[a-z0-9]+)*$` |

*变体 UnclassifiedMerchantWebhookEnvelope*

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `event` | `enum` | 必填 | 可选值：`payment.refund.completed`、`payment.transfer_refund.completed`、`payment.refund.failed`、`account.frozen`、`account.unfrozen` |
| `timestamp` | `string<date-time>` | 必填 | Time the event occurred (RFC 3339, may include fractional seconds and an offset). |
| `data` | `object` | 必填 | Merchant event data for event types that do not carry payment classification. |
| `data.classification_schema_version` | `—` | 可选 | 该事件不会包含此字段 |
| `data.classification` | `—` | 可选 | 该事件不会包含此字段 |

*变体 MerchantSettlementWebhookEnvelope*

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `schema_version` | `string` | 必填 | Body schema version. · 固定值 `1.0.0` |
| `event_id` | `string<uuid>` | 必填 | Original settlement event ID (UUIDv7); the deduplication key. |
| `event_type` | `enum` | 必填 | Settlement event type. · 可选值：`merchant_settlement.completed.v1`、`merchant_settlement.adjusted.v1`、`merchant_settlement.reversed.v1` |
| `occurred_at` | `string<date-time>` | 必填 | Event time in UTC. · 格式 `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?Z$` |
| `aggregate_type` | `string` | 必填 | Aggregate type; always merchant_settlement_item. · 固定值 `merchant_settlement_item` |
| `aggregate_id` | `string<uuid>` | 必填 | Settlement item ID; equals data.merchant_item_id. |
| `aggregate_version` | `integer` | 必填 | Settlement item version that produced the event. · ≥ 1 |
| `data` | `object` | 必填 | Public settlement item projection. Amounts are non-negative decimal strings in minor units, never JSON numbers, and satisfy gross_amount = merchant_fee_amount + net_amount. |
| `data.merchant_item_id` | `string<uuid>` | 必填 | Settlement item ID. |
| `data.merchant_account_id` | `string<uuid>` | 必填 | Merchant account ID; equals X-Merchant-Account-ID. |
| `data.source_type` | `enum` | 必填 | Source of the settlement item. merchant_settlement.completed.v1 is only delivered for invoice items; adjusted and reversed events use adjustment and reversal respectively. · 可选值：`invoice`、`transfer`、`refund`、`chargeback`、`adjustment`、`reversal` |
| `data.status` | `enum` | 必填 | Item status; settled, adjusted or reversed for the respective event type. · 可选值：`settled`、`adjusted`、`reversed` |
| `data.gross_amount` | `string` | 必填 | Gross amount as a decimal string in minor units. · 格式 `^(0\|[1-9][0-9]*)$` |
| `data.merchant_fee_amount` | `string` | 必填 | Merchant fee as a decimal string in minor units. · 格式 `^(0\|[1-9][0-9]*)$` |
| `data.net_amount` | `string` | 必填 | Net amount as a decimal string in minor units. · 格式 `^(0\|[1-9][0-9]*)$` |
| `data.currency` | `string` | 必填 | ISO 4217 currency code. · 格式 `^[A-Z]{3}$` |
| `data.due_at` | `string<date-time>` | 必填 | Time the item became due for settlement, in UTC. · 格式 `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?Z$` |
| `data.settled_at` | `string<date-time>` | 必填 | Time the item was settled, in UTC. · 格式 `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?Z$` |
| `data.parent_item_id` | `string,null<uuid>` | 必填 | Parent settlement item for adjustments and reversals; null otherwise. |
| `data.relation_type` | `enum` | 必填 | Relation to parent_item_id; null when there is no parent. · 可选值：`adjustment`、`reversal`、`null` |

*变体 WithdrawalStatusWebhookEnvelope*

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `event_type` | `enum` | 必填 | Withdrawal event type; equals X-Webhook-Event. · 可选值：`withdrawal.completed`、`withdrawal.failed` |
| `withdrawal_id` | `string<uuid>` | 必填 | PSP withdrawal ID. |
| `status` | `enum` | 必填 | Terminal withdrawal status. · 可选值：`completed`、`failed` |
| `timestamp` | `string<date-time>` | 必填 | Workflow time of the status change, UTC with second precision. |
| `amount` | `string` | 可选 | Withdrawal amount as a decimal string; present only when populated. |
| `currency` | `string` | 可选 | ISO 4217 currency code; present only when populated. |
| `channel_ref` | `string` | 可选 | Channel reference; present only when populated. |
| `e2e_id` | `string` | 可选 | End-to-end payment identifier; present only when populated. |
| `error_code` | `string` | 可选 | Failure code; present only when populated. |
| `error_message` | `string` | 可选 | Failure message; present only when populated. |

*变体 SettlementStatusWebhookEnvelope*

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `event_type` | `enum` | 必填 | Settlement event type; equals X-Webhook-Event. · 可选值：`settlement.completed`、`settlement.failed`、`settlement.rejected`、`settlement.canceled` |
| `settlement_id` | `string<uuid>` | 必填 | PSP settlement ID. |
| `status` | `enum` | 必填 | Settlement status. · 可选值：`completed`、`failed`、`rejected`、`canceled` |
| `timestamp` | `string<date-time>` | 必填 | Workflow time of the status change, UTC with second precision. |
| `net_amount` | `string` | 必填 | Settlement amount as a decimal string of integer minor units. · 格式 `^-?(0\|[1-9][0-9]*)$` |
| `currency` | `string` | 可选 | ISO 4217 currency code; omitted when the settlement has no currency. |

**请求示例**

```bash
curl -X POST 'https://<merchantWebhookHost>/<your-webhook-path>' \
  -H 'X-Timestamp: <timestamp>' \
  -H 'X-Signature: <signature>' \
  -H 'X-Signature-Algorithm: <signature-algorithm>' \
  -H 'X-Webhook-Key-ID: <webhook-key-id>' \
  -H 'X-Webhook-Version: <webhook-version>' \
  -H 'X-Webhook-ID: <webhook-id>' \
  -H 'X-Webhook-Event: <webhook-event>' \
  -H 'X-Webhook-Event-ID: <webhook-event-id>' \
  -H 'X-Merchant-Account-ID: <merchant-account-id>' \
  -H 'Content-Type: application/json' \
  -d '{
    "event": "payment.intent.completed",
    "timestamp": "2026-01-01T00:00:00Z",
    "data": {
      "classification_schema_version": 0,
      "classification": {
        "classification_status": "string",
        "payment_category": "string",
        "payment_method": "string",
        "rail_variant": "string"
      }
    }
  }'
```

**响应**

#### 响应 200 OK

Event accepted.

#### 响应 204 No Content

Event accepted without a response body.
