# ShortPay 认证管理 API 完整参考

本文公开可读，以下业务和管理操作仍执行原有 Bearer、Cookie、CSRF、站点和角色权限。示例均为虚构占位符。新业务网站付款请优先使用 [公共 Stripe 子集](/v1/docs?format=markdown) 和 [集成指南](/v1/docs?format=integration-skill)，不要混用两套签名和对象。

## 健康检查与管理员浏览器会话

`GET /healthz` is public and returns `{"ok":true,"service":"shortpay"}`. `GET /readyz` returns `{"ok":true,"service":"shortpay","backend":"sqlite"}` or `"postgresql"`; when its database check fails it returns `503 {"ok":false,"service":"shortpay"}`.

`POST /admin/auth/login` is browser-only: it requires exact configured `Origin`, `Content-Type: application/json`, and exactly `{"key":"ADMIN_OR_SCOPED_KEY"}` (key length 1–4096). It sets an HttpOnly session cookie and returns `{"authenticated":true,"role":"site|merchant|platform","site_ids":["site_demo"],"csrf_token":"...","expires_at":UNIX_SECONDS}`. `GET /admin/auth/session` returns the same metadata for its Cookie. `POST /admin/auth/logout` requires the Cookie, exact Origin, and `X-CSRF-Token`; it returns `{"authenticated":false}` and clears the cookie. These routes do not create or reveal API credentials.

## 认证、权限与传输

Use `Authorization: Bearer YOUR_SITE_API_KEY` only from a server you control. A key resolves to a Site, Merchant, or Platform actor. A Site key is limited to one site; merchant and platform keys may cover several sites. Any endpoint that selects a site requires `?site_id=AUTHORIZED_SITE_ID` when the actor has more than one site. Reads outside scope intentionally return `404 {"detail":"not_found"}`.

The `/admin` sign-in flow creates an HttpOnly Cookie session. It is accepted only by its route allowlist; Cookie POSTs require the configured exact `Origin` and `X-CSRF-Token`. Do not treat it as an API key.

There is **no read-only key type**. A site Bearer key can create checkout sessions and refunds. Constrain data-reading agents by policy and allowed GET calls, not by a claimed credential guarantee.

All JSON request bodies must be objects with unique keys and are limited to 32 KiB. Unknown fields are rejected where an endpoint lists an exact field set. Responses are JSON unless noted. Typical errors are `{"detail":"machine_readable_code"}`. All responses use `Cache-Control: no-store`, `Referrer-Policy: no-referrer`, and `X-Content-Type-Options: nosniff`.

Use integer `amount_minor` with its ISO currency; never submit floats. `401` means invalid/missing credentials, `403` a role/Origin/CSRF restriction, `404` may be an isolation boundary, `409` a state/idempotency conflict, `422 capability_unavailable`, `502` an unverified provider outcome, and `503` missing required configuration.

## 商户、站点与客户资源

### Merchant and site reads

`GET /v1/merchants` returns only merchants visible to the actor:

```json
{"data":[{"id":"merchant_demo","name":"Demo Merchant","seller":{"name":"Demo Merchant LLC","address":"1 Main St","tax_id":"optional-tax-id"}}]}
```

`seller.name` is required; `seller.address` and `seller.tax_id` are optional strings. `GET /v1/sites?merchant_id=MERCHANT_ID` returns the actor-visible sites (the query is optional) and the shared channel summary:

```json
{"data":[{"id":"site_demo","merchant_id":"merchant_demo","name":"Demo Store","provider_account_id":"account_demo","event_url":"https://merchant.example/webhooks/shortpay","return_url":"https://merchant.example/orders/return","embed_origins":["https://merchant.example"]}],"channel":{"id":"account_demo","provider":"sunrate","test_mode":true,"configured":true}}
```

Platform-only `POST /v1/merchants` requires exactly:

```json
{"id":"merchant_demo","name":"Demo Merchant","seller":{"name":"Demo Merchant LLC","address":"1 Main St","tax_id":"optional-tax-id"}}
```

Platform-only `POST /v1/sites` requires exactly:

```json
{"id":"site_demo","name":"Demo Store","merchant_id":"merchant_demo","event_url":"https://merchant.example/webhooks/shortpay","return_url":"https://merchant.example/orders/return","embed_origins":["https://merchant.example"]}
```

IDs match `[A-Za-z][A-Za-z0-9_-]{0,63}`. Names are nonempty and at most 200 characters. URLs are valid HTTP(S) URLs under server policy; `embed_origins` has 1–20 unique origins with no path. Creation returns the submitted object with `201`; an identical administrator-created replay returns it with `"replayed":true` and `200`. Merchant/site creation does not return credentials. `POST /v1/sites/{site_id}/credentials` is an explicit sensitive reveal for an authorized merchant/platform actor and returns `{"api_key":"...","event_secret":"..."}`; agents must never invoke it as discovery or onboarding automation.

### Customer requests

`POST /management/customers` accepts only these fields; none is required by the creation endpoint itself:

```json
{"external_customer_id":"user-42","customer_email":"buyer@example.com","billing_address":{"country":"US","postal_code":"02110"}}
```

`external_customer_id`, when supplied, is a nonempty trimmed string of at most 200 characters without control characters and is unique only within the selected site. `customer_email`, when supplied, is normalized and must be a valid mailbox no longer than 254 characters. `billing_address`, when supplied, is **exactly** `{"country":"ISO-3166-1-alpha-2","postal_code":"string"}`; postal code is at most 20 characters and may be empty only for the implementation's postal-optional countries. Email is contact data, not proof of identity or a unique key.

A successful customer response from create, get, lookup, or patch has this shape (fields can be `null` when not supplied):

```json
{"id":"cus_example","site_id":"site_demo","merchant_id":"merchant_demo","external_customer_id":"user-42","email":"buyer@example.com","created_at":"2026-09-14T12:00:00Z","updated_at":"2026-09-14T12:00:00Z","billing_address":{"country":"US","postal_code":"02110"}}
```

`GET /management/customers?external_customer_id=USER_ID` requires a valid nonempty query value and returns `{"data":[customer]}` or `{"data":[]}`. `GET /management/customers/{customer_id}` returns one customer. `PATCH /management/customers/{customer_id}` requires a nonempty subset of exactly `customer_email` and/or `billing_address`, validates them as above, and returns the same customer shape. Customer reads/writes require Bearer authentication.

## 管理 Checkout Session

### Required body, identity, and idempotency

`POST /management/checkout-sessions` requires a nonempty `Idempotency-Key` of at most 200 characters. The key is scoped to the selected site. The same key plus the canonical identical JSON body returns the original response with `200`; the same key plus a different body returns `409 idempotency_conflict`. `external_order_id` is unique per site, so a different key cannot create it twice (`409 external_order_conflict`).

The request body accepts only the fields below. `external_order_id`, `description`, `amount_minor`, `currency`, `billing_address`, `line_items`, and **one customer identity** are required for a successful payment checkout.

```json
{
  "external_order_id":"merchant-order-123",
  "external_customer_id":"merchant-user-42",
  "customer_email":"buyer@example.com",
  "billing_address":{"country":"US","postal_code":"02110"},
  "line_items":[{"external_product_id":"credits-120","name":"120 credits","quantity":1,"unit_amount_minor":1200,"discount_amount_minor":0}],
  "amount_minor":1200,
  "currency":"USD",
  "description":"120 credits",
  "mode":"payment",
  "checkout_mode":"redirect"
}
```

Use **exactly one** of these alternatives:

* `customer_id`: an existing `cus_...` customer belonging to the selected site; or
* `external_customer_id`: a stable nonempty site-local ID. It resolves an existing customer or creates one.

Supplying both returns `400 choose_one_customer_identity`; supplying neither during checkout returns `400 customer_identity_required`. `customer_email` is required when the selected/resolved customer has no email. If that customer already has an email, omitting `customer_email` preserves it, while a different supplied email returns `409 customer_email_readonly`. Change an existing email first with `PATCH /management/customers/{id}`. Checkout updates the current customer billing address, but it creates an immutable order/invoice snapshot; later customer edits never change past orders.

`external_order_id` and `description` are nonempty strings of at most 200 characters. `description` must not contain `#`, `&`, `+`, or control characters. `amount_minor` is an integer from 1 through 1,000,000,000. `currency` must be a server-supported currency. `mode` defaults to `payment`; `subscription` returns `501 subscription_not_implemented`, and any other value is invalid. `checkout_mode` defaults to `redirect` and is `redirect` or `embedded`.

`billing_address` is mandatory for checkout and has the exact validation above. `line_items` is mandatory and contains 1–100 objects, each with exactly `external_product_id`, `name`, `quantity`, `unit_amount_minor`, and `discount_amount_minor`. Product ID and name are nonempty <=200-character text values without control characters; quantity is 1..1,000,000,000; amounts are integer 0..1,000,000,000. For every line, `discount_amount_minor <= quantity * unit_amount_minor`; each gross line and the overall total must be <=1,000,000,000; and:

```text
sum(quantity * unit_amount_minor - discount_amount_minor) == amount_minor
```

### Checkout success and protected session response

A new create returns `201`; an idempotent replay returns `200`. The response includes the short-lived purchaser capability **only here and on access renewal**:

```json
{
  "id":"cs_example",
  "payment_id":"pay_example",
  "status":"open",
  "amount_minor":1200,
  "currency":"USD",
  "expires_at":"2026-09-14T12:30:00Z",
  "checkout_url":"https://shortpay.example/checkout/cs_example?token=SHORT_LIVED_CAPABILITY",
  "merchant_id":"merchant_demo",
  "site_id":"site_demo",
  "provider_account_id":"account_demo",
  "customer_id":"cus_example",
  "customer_email":"buyer@example.com",
  "billing_address":{"country":"US","postal_code":"02110"},
  "line_items":[{"external_product_id":"credits-120","name":"120 credits","quantity":1,"unit_amount_minor":1200,"discount_amount_minor":0}],
  "seller":{"name":"Demo Merchant LLC","address":"1 Main St","tax_id":"optional-tax-id"},
  "brand":"Demo Store",
  "test_mode":true,
  "external_order_id":"merchant-order-123",
  "invoice":null
}
```

The literal `token=SHORT_LIVED_CAPABILITY` above is illustrative: the actual URL is generated as `PUBLIC_BASE_URL + "/checkout/" + session id + "?token=" + current session token`. It is not a `checkout_url?token=...` suffix and must be passed unchanged to the purchaser or to `ShortPay.open`. Do not log, persist in analytics, or expose the URL/token to anyone other than the verified purchaser.

`GET /management/checkout-sessions/{id}` returns a protected session/snapshot view without `checkout_url` or token:

```json
{"id":"cs_example","payment_id":"pay_example","external_order_id":"merchant-order-123","amount_minor":1200,"currency":"USD","description":"120 credits","status":"open","created_at":"2026-09-14T12:00:00Z","expires_at":"2026-09-14T12:30:00Z","attempt_status":null,"merchant_id":"merchant_demo","site_id":"site_demo","provider_account_id":"account_demo","customer_id":"cus_example","customer_email":"buyer@example.com","billing_address":{"country":"US","postal_code":"02110"},"line_items":[{"external_product_id":"credits-120","name":"120 credits","quantity":1,"unit_amount_minor":1200,"discount_amount_minor":0}],"seller":{"name":"Demo Merchant LLC"},"brand":"Demo Store","test_mode":true,"invoice":null}
```

`POST /management/checkout-sessions/{id}/access` has no request body. After the merchant has independently verified the purchaser owns the order, it invalidates the old capability, extends expiry, and returns the same checkout-create response shape with a newly generated `checkout_url`.

### Embedded and hosted purchaser flow

A session must have been created with `"checkout_mode":"embedded"`. The merchant backend, never the browser, calls:

```http
POST /management/checkout-sessions/cs_example/embedded
Authorization: Bearer YOUR_SITE_API_KEY
Content-Type: application/json

{"payment_methods":["card"],"os_type":"WINDOWS","client_ip":"203.0.113.10"}
```

The body must contain **exactly** `payment_methods`, `os_type`, and `client_ip`. Methods are a nonempty distinct subset of `card`, `google_pay`, and `apple_pay`; `os_type` is `IOS`, `ANDROID`, `WINDOWS`, `MAC`, or `OTHER`; `client_ip` is a normalized IPv4/IPv6 literal at most 45 characters. It can return:

```json
{"session_id":"cs_example","payment_id":"pay_example","status":"pending","environment":"test","payment_methods":["card"],"paySession":"TEMPORARY_PROVIDER_SESSION"}
```

`paySession` is omitted only when the current session is terminal. A replay can return the saved paySession; a previous unresolved attempt without one returns `409 original_attempt_pending_or_unknown`. The response never includes a provider key, MID, signing key, or full provider response.

Hosted purchaser calls authenticate with the token in the actual checkout URL: `GET /checkout/{id}?token=...`, `GET /checkout-api/{id}?token=...`, `POST /checkout-api/{id}/pay?token=...`, and the conditional QR endpoint. The checkout view includes the following core fields (plus capability-scoped buyer prefill, identity_frozen, can_resume, test_mode, embed_origins, embedded_id and environment for hosted rendering):

```json
{"id":"cs_example","status":"open","amount_minor":1200,"currency":"USD","currency_exponent":2,"description":"120 credits","merchant_name":"Demo Store","methods":[{"id":"card","label":"Card"}],"action":null,"return_url":"https://merchant.example/orders/return","expires_at":"2026-09-14T12:30:00Z"}
```

Purchaser payment POST accepts only `{"method_id":"card","os_type":"WINDOWS"}`; `os_type` is optional and defaults to `OTHER`. Only one provider attempt is permitted. It returns the same safe object; its `action` is `null`, `{"type":"redirect","url":"https://approved-provider.example/..."}`, or `{"type":"qrcode","code_url":"...","image_url":"https://shortpay.example/checkout-api/cs_example/qr?token=..."}`. A browser redirect/action is not payment finality.

`GET /embed.js` defines `window.ShortPay.open({ checkoutUrl, onStatus, onClose })`. `checkoutUrl` must be the unmodified ShortPay `checkout_url`: its origin must equal the script origin, its path must match `/checkout/{id}`, and it must contain `token`. `open` returns `{close()}`; `onStatus` receives `{sessionId, status:"succeeded"}` only as a display notification, and `onClose` is called when the modal closes. Neither callback authorizes fulfillment.

## 付款快照、退款与发票

`GET /management/payments?limit=50&offset=0&status=succeeded&external_order_id=merchant-order-123&email=buyer@example.com&site_id=site_demo&merchant_id=merchant_demo` is the sole paginated list. `limit` is 1..100 (default 50), `offset` is >=0 (default 0), `status` <=40 characters, `external_order_id` <=200, and `email` <=254. Email is a case-insensitive literal substring search of the historical order snapshot, not a customer lookup. The response is `{"data":[payment_view],"has_more":false}`; it has no total/cursor and can shift during concurrent writes.

Each list `payment_view` contains the immutable snapshot fields shown below. `GET /management/payments/{payment_id}` returns these fields plus `id` (checkout-session ID), `attempt_status`, `method_id`, `attempt_updated_at`, and `paid_at`:

```json
{
  "payment_id":"pay_example",
  "session_id":"cs_example",
  "external_order_id":"merchant-order-123",
  "external_customer_id":"merchant-user-42",
  "amount_minor":1200,
  "currency":"USD",
  "currency_exponent":2,
  "status":"succeeded",
  "created_at":"2026-09-14T12:00:00Z",
  "expires_at":"2026-09-14T12:30:00Z",
  "description":"120 credits",
  "payment_brand":"visa",
  "refunded_amount_minor":200,
  "reserved_amount_minor":0,
  "refundable_amount_minor":1000,
  "refund_available":true,
  "merchant_id":"merchant_demo",
  "site_id":"site_demo",
  "provider_account_id":"account_demo",
  "customer_id":"cus_example",
  "customer_email":"buyer@example.com",
  "billing_address":{"country":"US","postal_code":"02110"},
  "line_items":[{"external_product_id":"credits-120","name":"120 credits","quantity":1,"unit_amount_minor":1200,"discount_amount_minor":0}],
  "seller":{"name":"Demo Merchant LLC"},
  "brand":"Demo Store",
  "test_mode":true,
  "invoice":{"id":"inv_example","number":"merchant_demo-EXAMPLE"},
  "id":"cs_example",
  "attempt_status":"succeeded",
  "method_id":"card",
  "attempt_updated_at":"2026-09-14T12:02:00Z",
  "paid_at":"2026-09-14T12:02:00Z"
}
```

`refunded_amount_minor` is verified successful refunds. `reserved_amount_minor` includes `created`, `pending`, `unknown`, and `failed` refunds. `refundable_amount_minor` is amount minus both sums. `refund_available` additionally requires a succeeded payment, verified payment brand, configured refund callback/provider, and positive available balance; it is not approval to refund.

`POST /management/payments/{payment_id}/reconcile` has no body and asks the configured provider about the existing original order. It never creates a payment. A signed query only transitions to paid when the account-specific finality setting allows it; otherwise a query success is not fulfillment proof. It returns the protected session inspection shape (`id`, `payment_id`, `external_order_id`, amount/currency/description/status, created/expires, attempt_status) or `502 query_unverified_or_unavailable`.

`POST /management/refunds` requires an Idempotency-Key with the same limits/replay behavior as checkout and exactly:

```json
{"payment_id":"pay_example","amount_minor":200,"reason":"Customer requested partial refund"}
```

`amount_minor` is a positive integer; `reason` is a nonempty <=200-character string. ShortPay obtains currency from the original payment. New requests return `201`, identical idempotent replays `200`, and both return:

```json
{"id":"rfd_example","payment_id":"pay_example","amount_minor":200,"currency":"USD","status":"pending","reason":"Customer requested partial refund","created_at":"2026-09-14T13:00:00Z"}
```

The only refund response fields are `id`, `payment_id`, `amount_minor`, `currency`, `status`, `reason`, and `created_at`. `GET /management/refunds/{id}` returns this object; `GET /management/payments/{payment_id}/refunds` returns `{"data":[refund]}`. `created`, `pending`, `unknown`, and `failed` keep the amount reserved; only verified `succeeded` completes a refund. Do not issue another refund after an ambiguous outcome.

`POST /management/refunds/{id}/reconcile` has no body and only queries the existing provider refund. It returns `409 refund_query_finality_not_enabled` without an upstream call when account-specific finality is off; otherwise it returns the refund object on verified final success or `502` for unverified/ambiguous/unavailable results.

A verified successful payment creates one immutable paid invoice. `GET /management/invoices/{id}` returns the original snapshot, then these invoice fields:

```json
{
  "merchant_id":"merchant_demo",
  "site_id":"site_demo",
  "provider_account_id":"account_demo",
  "customer_id":"cus_example",
  "customer_email":"buyer@example.com",
  "billing_address":{"country":"US","postal_code":"02110"},
  "line_items":[{"external_product_id":"credits-120","name":"120 credits","quantity":1,"unit_amount_minor":1200,"discount_amount_minor":0}],
  "seller":{"name":"Demo Merchant LLC"},
  "brand":"Demo Store",
  "test_mode":true,
  "amount_minor":1200,
  "currency":"USD",
  "external_order_id":"merchant-order-123",
  "payment_id":"pay_example",
  "status":"paid",
  "id":"inv_example",
  "number":"merchant_demo-EXAMPLE",
  "created_at":"2026-09-14T12:02:00Z",
  "pdf_status":"ready",
  "refunds":[{"id":"rfd_example","status":"succeeded","amount_minor":200,"currency":"USD"}]
}
```

`GET /management/invoices/{id}/html` returns sandboxed HTML. `GET /management/invoices/{id}/pdf` returns `application/pdf` with an attachment filename when ready, or `202` with `Retry-After: 15` while PDF generation is pending. Retry PDF retrieval, never payment creation.

## 管理事件、签名与上游回调

ShortPay asynchronously delivers `payment.succeeded`, `payment.failed`, `refund.succeeded`, and `refund.failed` to the configured site event URL. Every event body returned by `GET /management/events/{id}` has this envelope:

```json
{"id":"evt_example","type":"payment.succeeded","api_version":"v1","created_at":"2026-09-14T12:02:00Z","site_id":"site_demo","merchant_id":"merchant_demo","resource_id":"pay_example","external_order_id":"merchant-order-123","data":{"payment_id":"pay_example","amount_minor":1200,"currency":"USD","status":"succeeded"}}
```

For refund events, `resource_id` is the refund ID and `data` is exactly:

```json
{"refund_id":"rfd_example","payment_id":"pay_example","amount_minor":200,"currency":"USD","status":"succeeded"}
```

Delivery headers are `X-ShortPay-Event-Id`, `X-ShortPay-Timestamp` (Unix seconds), and `X-ShortPay-Signature: sha256=<hex>`. Verify `sha256=` + `HMAC-SHA256(event_secret, timestamp + "." + raw_request_body)` in constant time before parsing JSON. Persist event IDs and your fulfillment/refund business IDs uniquely in the same transaction. HTTP 2xx acknowledges delivery; non-2xx/transport failures retry up to 10 total attempts using the same event ID and a newly generated timestamp/signature.

`GET /management/events/{id}/delivery` returns exactly `{"event_id":"evt_example","attempts":1,"last_error":null,"delivered_at":"2026-09-14T12:03:00Z","dead":false,"next_at":"2026-09-14T12:02:00Z"}`. `POST /management/events/{id}/retry` has no body and returns `{"id":"evt_example","retry_requested":true}`; it requeues only an undelivered event. Management delivery events have no list endpoint. Site Bearer `GET /v1/events` lists separate immutable revenue success snapshots; see [RR revenue API](/v1/docs?format=markdown).

`POST /webhooks/sunrate/payment` and `/webhooks/sunrate/refund` are provider-only signed callbacks; they reply plain `OK` only after verification/persistence. Browser redirects, hosted checkout actions, and provider create responses are not fulfillment proof.

## 尚未支持

Subscriptions, recurring/MIT, customer portal, settlement report import/reconciliation, automatic reconciliation scheduling, public OpenAPI, client-side payment keys, and a read-only API credential type are not implemented. Do not infer provider/account approval from this contract.


## 当前托管卡片页面的 capability 接口

以下是收银台内部协议，业务网站使用 Session URL 或 `/embed.js`，不要自行收集或转发卡号、CVV。短期 token 仅授权该购买者会话，不代替商户 API Key。

- `POST /checkout-api/{session_id}/pay-card?token=...`：要求与 Checkout origin 精确相同的 `Origin` 和 `Content-Type: application/json`。
- JSON 顶层恰为 `email`、`name`、`billing_address`、`os_type`、`card`。
- `billing_address` 必须包含 `country`、`postal_code`，可选 `line1`、`city`、`state`、`phone`；校验并保存订单快照。
- `card` 恰为字符串 `number`（13–19 位 ASCII 数字且通过 Luhn）、`cvv`（3–4 位数字）、`expirationMonth`（01–12）、`expirationYear`（两位年份，未过期）。这些敏感数据不得持久化、记录或发送至分析工具。
- `os_type` 为 `IOS`、`ANDROID`、`WINDOWS`、`MAC`、`OTHER`。金额、账户和可信客户端 IP 由服务端派生。
- 返回与 `GET /checkout-api/{session_id}?token=...` 相同的安全视图。重复提交复用已有尝试，`pending`/`unknown` 不创建另一笔付款；认证跳转和创建响应都不能证明已收款。
- 兼容保留 `POST /checkout-api/{session_id}/prepare?token=...`：JSON 恰为 `email`、`name`、`billing_address`（country/postal_code）、`os_type`，响应 `{ "paySession":"SHORT_LIVED_PROVIDER_SESSION" }`；依赖该环境的嵌入能力。当前托管直接卡片页面不使用该旧 SDK prepare 流程。
