{"openapi":"3.1.0","info":{"title":"Prosoft Pay API","description":"Multi-tenant payment gateway platform. See https://api-sandbox.prosoftpay.com/docs for the interactive spec.","contact":{"name":"Newmark Group — Prosoft Pay","url":"https://prosoftpay.com/"},"license":{"name":"Proprietary","identifier":"LicenseRef-Proprietary"},"version":"0.1.0"},"servers":[{"url":"https://api-sandbox.prosoftpay.com","description":"Sandbox"},{"url":"https://api.prosoftpay.com","description":"Production"},{"url":"http://localhost:8000","description":"Local development"}],"paths":{"/v1/ping":{"get":{"tags":["meta"],"summary":"Ping","description":"Simple, unauthenticated health check. Returns `pong` when the API is reachable.","operationId":"ping_v1_ping_get","responses":{"200":{"description":"Confirmation that the API is reachable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PingResponse"}}}}},"security":[]}},"/v1/payments":{"post":{"tags":["payments"],"summary":"Create a payment intent","description":"Create a new payment intent and authorize it synchronously against the configured bank connector. With `capture_mode=\"automatic\"` (default), the payment is authorized and captured in the same call, returning state `captured`. With `capture_mode=\"manual\"`, only authorization runs, returning state `authorized` (a separate capture is required to complete it -- see `PaymentService.capture()`). If the bank declines, the payment is returned with state `failed` (still a 201: the payment *intent* was created successfully, its *outcome* is a decline). If `capture_mode=\"automatic\"` authorizes successfully but the immediate automatic capture attempt itself fails (bank decline or connector timeout), this still returns a 201 with state `authorized` and `capture_error` populated -- the authorization is real and durable, only the capture step didn't complete; it is never rolled back to look as though the bank was never called. Requires an `Idempotency-Key` header: a retry with the same key and an identical body returns the original response without reprocessing; the same key with a different body is rejected with 409.","operationId":"create_payment_v1_payments_post","parameters":[{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string","description":"Client-generated UUIDv4 unique to this logical request.","examples":["9a8b7c6d-0000-4000-8000-000000000001"],"title":"Idempotency-Key"},"description":"Client-generated UUIDv4 unique to this logical request."},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-ProsoftPay-Timestamp","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Prosoftpay-Timestamp"}},{"name":"X-ProsoftPay-Nonce","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Prosoftpay-Nonce"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentCreateRequest"}}}},"responses":{"201":{"description":"The created payment, reflecting its post-authorization state.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentResponse"}}}},"401":{"description":"Merchant-API authentication failed: missing/malformed `Authorization`, `X-ProsoftPay-Timestamp`, or `X-ProsoftPay-Nonce` header (`auth_missing`, `auth_malformed`, `auth_incomplete`); unknown API key (`key_not_found`); a key issued before signature support existed (`key_needs_rotation`) or whose stored secret failed to decrypt (`key_decryption_failed`); a timestamp outside the ±5 minute tolerance (`timestamp_expired`) or unparseable (`invalid_timestamp`); a signature mismatch (`signature_invalid`); or a nonce already used within the tolerance window (`replay_detected`). See `docs/API_SPEC.md`'s Authentication section.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The API key is known but not active (`key_inactive`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Rate limit exceeded for this API key (`rate_limited`) -- a sliding 60-second window, see `app/core/rate_limit.py`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Request validation failed -- malformed input (`validation_error`), or a semantic validation failure raised by business logic (e.g. an unparseable `tenant_id` filter, code `invalid_tenant_id`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"The request body could not be parsed -- not valid UTF-8, or not valid JSON (`invalid_request`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Idempotency-Key reused with a different body (`idempotency_key_conflict`), or the same key/body is still being processed (`request_in_progress`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"The bank connector did not respond in time; outcome unknown (`bank_timeout`). The `Idempotency-Key` reservation is left unresolved, so retrying with the same key returns 409, not a silent reprocess.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"MerchantHmac":[]}]},"get":{"tags":["payments"],"summary":"List payments","description":"List the authenticated tenant's payments, most-recent-first, with cursor-based pagination (`docs/API_SPEC.md` \"Pagination\"). Optionally filter by `state`.","operationId":"list_payments_v1_payments_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"Max payments to return.","default":25,"title":"Limit"},"description":"Max payments to return."},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Opaque cursor from a previous page.","title":"Cursor"},"description":"Opaque cursor from a previous page."},{"name":"state","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/PaymentState"},{"type":"null"}],"description":"Filter by payment state.","title":"State"},"description":"Filter by payment state."},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-ProsoftPay-Timestamp","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Prosoftpay-Timestamp"}},{"name":"X-ProsoftPay-Nonce","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Prosoftpay-Nonce"}}],"responses":{"200":{"description":"A page of payments plus pagination metadata.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentListResponse"}}}},"401":{"description":"Merchant-API authentication failed: missing/malformed `Authorization`, `X-ProsoftPay-Timestamp`, or `X-ProsoftPay-Nonce` header (`auth_missing`, `auth_malformed`, `auth_incomplete`); unknown API key (`key_not_found`); a key issued before signature support existed (`key_needs_rotation`) or whose stored secret failed to decrypt (`key_decryption_failed`); a timestamp outside the ±5 minute tolerance (`timestamp_expired`) or unparseable (`invalid_timestamp`); a signature mismatch (`signature_invalid`); or a nonce already used within the tolerance window (`replay_detected`). See `docs/API_SPEC.md`'s Authentication section.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The API key is known but not active (`key_inactive`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Rate limit exceeded for this API key (`rate_limited`) -- a sliding 60-second window, see `app/core/rate_limit.py`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Request validation failed -- malformed input (`validation_error`), or a semantic validation failure raised by business logic (e.g. an unparseable `tenant_id` filter, code `invalid_tenant_id`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"The `cursor` query parameter is malformed (`invalid_cursor`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"MerchantHmac":[]}]}},"/v1/payments/{payment_id}":{"get":{"tags":["payments"],"summary":"Retrieve a payment","description":"Retrieve a single payment by its `id`. Only returns payments belonging to the authenticated tenant -- a payment ID from another tenant returns 404, identical to a nonexistent ID.","operationId":"get_payment_v1_payments__payment_id__get","parameters":[{"name":"payment_id","in":"path","required":true,"schema":{"type":"string","title":"Payment Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-ProsoftPay-Timestamp","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Prosoftpay-Timestamp"}},{"name":"X-ProsoftPay-Nonce","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Prosoftpay-Nonce"}}],"responses":{"200":{"description":"The requested payment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentResponse"}}}},"401":{"description":"Merchant-API authentication failed: missing/malformed `Authorization`, `X-ProsoftPay-Timestamp`, or `X-ProsoftPay-Nonce` header (`auth_missing`, `auth_malformed`, `auth_incomplete`); unknown API key (`key_not_found`); a key issued before signature support existed (`key_needs_rotation`) or whose stored secret failed to decrypt (`key_decryption_failed`); a timestamp outside the ±5 minute tolerance (`timestamp_expired`) or unparseable (`invalid_timestamp`); a signature mismatch (`signature_invalid`); or a nonce already used within the tolerance window (`replay_detected`). See `docs/API_SPEC.md`'s Authentication section.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The API key is known but not active (`key_inactive`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Rate limit exceeded for this API key (`rate_limited`) -- a sliding 60-second window, see `app/core/rate_limit.py`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Request validation failed -- malformed input (`validation_error`), or a semantic validation failure raised by business logic (e.g. an unparseable `tenant_id` filter, code `invalid_tenant_id`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"The request body could not be parsed -- not valid UTF-8, or not valid JSON (`invalid_request`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No such payment for this tenant (`not_found`) -- identical whether the id is malformed, doesn't exist, or belongs to another tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"MerchantHmac":[]}]}},"/v1/webhooks":{"post":{"tags":["webhooks"],"summary":"Register a webhook endpoint","description":"Register a new webhook endpoint for the authenticated tenant. Prosoft Pay generates the HMAC signing secret server-side and returns it **once**, in this response only -- it is never returned again by `GET`/`PATCH`/list. Store it immediately; rotate by deleting and recreating the endpoint. Requires an `Idempotency-Key` header: a retry with the same key and an identical body returns the original response without creating a second endpoint; the same key with a different body is rejected with 409.","operationId":"create_webhook_endpoint_v1_webhooks_post","parameters":[{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string","description":"Client-generated UUIDv4 unique to this logical request.","examples":["9a8b7c6d-0000-4000-8000-000000000002"],"title":"Idempotency-Key"},"description":"Client-generated UUIDv4 unique to this logical request."},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-ProsoftPay-Timestamp","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Prosoftpay-Timestamp"}},{"name":"X-ProsoftPay-Nonce","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Prosoftpay-Nonce"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointCreateRequest"}}}},"responses":{"201":{"description":"The newly created endpoint, including its plaintext secret.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointResponse"}}}},"401":{"description":"Merchant-API authentication failed: missing/malformed `Authorization`, `X-ProsoftPay-Timestamp`, or `X-ProsoftPay-Nonce` header (`auth_missing`, `auth_malformed`, `auth_incomplete`); unknown API key (`key_not_found`); a key issued before signature support existed (`key_needs_rotation`) or whose stored secret failed to decrypt (`key_decryption_failed`); a timestamp outside the ±5 minute tolerance (`timestamp_expired`) or unparseable (`invalid_timestamp`); a signature mismatch (`signature_invalid`); or a nonce already used within the tolerance window (`replay_detected`). See `docs/API_SPEC.md`'s Authentication section.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The API key is known but not active (`key_inactive`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Rate limit exceeded for this API key (`rate_limited`) -- a sliding 60-second window, see `app/core/rate_limit.py`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Invalid URL, or `events` empty / contains an unknown event type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"The URL's resolved address is private, loopback, link-local, multicast, or reserved (e.g. a cloud metadata endpoint or an internal address) -- rejected as an SSRF risk (`unsafe_webhook_url`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Idempotency-Key reused with a different body (`idempotency_key_conflict`), or already in progress (`request_in_progress`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"MerchantHmac":[]}]},"get":{"tags":["webhooks"],"summary":"List webhook endpoints","description":"List all webhook endpoints registered by the authenticated tenant, most-recent-first. Never includes the signing secret (only the creation response does). Deliberately unpaginated for the MVP -- a tenant's endpoint count is expected to stay in the single digits.","operationId":"list_webhook_endpoints_v1_webhooks_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-ProsoftPay-Timestamp","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Prosoftpay-Timestamp"}},{"name":"X-ProsoftPay-Nonce","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Prosoftpay-Nonce"}}],"responses":{"200":{"description":"All of the authenticated tenant's webhook endpoints.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointListResponse"}}}},"401":{"description":"Merchant-API authentication failed: missing/malformed `Authorization`, `X-ProsoftPay-Timestamp`, or `X-ProsoftPay-Nonce` header (`auth_missing`, `auth_malformed`, `auth_incomplete`); unknown API key (`key_not_found`); a key issued before signature support existed (`key_needs_rotation`) or whose stored secret failed to decrypt (`key_decryption_failed`); a timestamp outside the ±5 minute tolerance (`timestamp_expired`) or unparseable (`invalid_timestamp`); a signature mismatch (`signature_invalid`); or a nonce already used within the tolerance window (`replay_detected`). See `docs/API_SPEC.md`'s Authentication section.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The API key is known but not active (`key_inactive`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Rate limit exceeded for this API key (`rate_limited`) -- a sliding 60-second window, see `app/core/rate_limit.py`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Request validation failed -- malformed input (`validation_error`), or a semantic validation failure raised by business logic (e.g. an unparseable `tenant_id` filter, code `invalid_tenant_id`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"The request body could not be parsed -- not valid UTF-8, or not valid JSON (`invalid_request`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"MerchantHmac":[]}]}},"/v1/webhooks/{webhook_id}":{"get":{"tags":["webhooks"],"summary":"Retrieve a webhook endpoint","description":"Retrieve a single webhook endpoint by its `id`. Only returns endpoints belonging to the authenticated tenant -- an endpoint ID from another tenant returns 404, identical to a nonexistent ID. Never includes the signing secret.","operationId":"get_webhook_endpoint_v1_webhooks__webhook_id__get","parameters":[{"name":"webhook_id","in":"path","required":true,"schema":{"type":"string","title":"Webhook Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-ProsoftPay-Timestamp","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Prosoftpay-Timestamp"}},{"name":"X-ProsoftPay-Nonce","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Prosoftpay-Nonce"}}],"responses":{"200":{"description":"The requested webhook endpoint.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointResponse"}}}},"401":{"description":"Merchant-API authentication failed: missing/malformed `Authorization`, `X-ProsoftPay-Timestamp`, or `X-ProsoftPay-Nonce` header (`auth_missing`, `auth_malformed`, `auth_incomplete`); unknown API key (`key_not_found`); a key issued before signature support existed (`key_needs_rotation`) or whose stored secret failed to decrypt (`key_decryption_failed`); a timestamp outside the ±5 minute tolerance (`timestamp_expired`) or unparseable (`invalid_timestamp`); a signature mismatch (`signature_invalid`); or a nonce already used within the tolerance window (`replay_detected`). See `docs/API_SPEC.md`'s Authentication section.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The API key is known but not active (`key_inactive`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Rate limit exceeded for this API key (`rate_limited`) -- a sliding 60-second window, see `app/core/rate_limit.py`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Request validation failed -- malformed input (`validation_error`), or a semantic validation failure raised by business logic (e.g. an unparseable `tenant_id` filter, code `invalid_tenant_id`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"The request body could not be parsed -- not valid UTF-8, or not valid JSON (`invalid_request`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No such webhook endpoint for this tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"MerchantHmac":[]}]},"patch":{"tags":["webhooks"],"summary":"Update a webhook endpoint","description":"Update a webhook endpoint's `url` and/or `events`. Only provided fields are changed. Does not accept `status` -- reactivating a disabled endpoint is not supported this sprint (recreate it via `POST` instead). Requires an `Idempotency-Key` header, with the same replay semantics as `POST`.","operationId":"update_webhook_endpoint_v1_webhooks__webhook_id__patch","parameters":[{"name":"webhook_id","in":"path","required":true,"schema":{"type":"string","title":"Webhook Id"}},{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string","description":"Client-generated UUIDv4 unique to this logical request.","examples":["9a8b7c6d-0000-4000-8000-000000000002"],"title":"Idempotency-Key"},"description":"Client-generated UUIDv4 unique to this logical request."},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-ProsoftPay-Timestamp","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Prosoftpay-Timestamp"}},{"name":"X-ProsoftPay-Nonce","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Prosoftpay-Nonce"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointUpdateRequest"}}}},"responses":{"200":{"description":"The updated webhook endpoint.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointResponse"}}}},"401":{"description":"Merchant-API authentication failed: missing/malformed `Authorization`, `X-ProsoftPay-Timestamp`, or `X-ProsoftPay-Nonce` header (`auth_missing`, `auth_malformed`, `auth_incomplete`); unknown API key (`key_not_found`); a key issued before signature support existed (`key_needs_rotation`) or whose stored secret failed to decrypt (`key_decryption_failed`); a timestamp outside the ±5 minute tolerance (`timestamp_expired`) or unparseable (`invalid_timestamp`); a signature mismatch (`signature_invalid`); or a nonce already used within the tolerance window (`replay_detected`). See `docs/API_SPEC.md`'s Authentication section.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The API key is known but not active (`key_inactive`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Rate limit exceeded for this API key (`rate_limited`) -- a sliding 60-second window, see `app/core/rate_limit.py`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Request validation failed -- malformed input (`validation_error`), or a semantic validation failure raised by business logic (e.g. an unparseable `tenant_id` filter, code `invalid_tenant_id`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"The new URL's resolved address is private, loopback, link-local, multicast, or reserved -- rejected as an SSRF risk (`unsafe_webhook_url`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No such webhook endpoint for this tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Idempotency-Key reused with a different body (`idempotency_key_conflict`), or already in progress (`request_in_progress`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"MerchantHmac":[]}]},"delete":{"tags":["webhooks"],"summary":"Deactivate a webhook endpoint","description":"Deactivate a webhook endpoint. This does not hard-delete the row -- it marks the endpoint `disabled` so it stops receiving new deliveries, while keeping its delivery history intact for audit/debugging. Requires an `Idempotency-Key` header, with the same replay semantics as `POST`.","operationId":"delete_webhook_endpoint_v1_webhooks__webhook_id__delete","parameters":[{"name":"webhook_id","in":"path","required":true,"schema":{"type":"string","title":"Webhook Id"}},{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string","description":"Client-generated UUIDv4 unique to this logical request.","examples":["9a8b7c6d-0000-4000-8000-000000000002"],"title":"Idempotency-Key"},"description":"Client-generated UUIDv4 unique to this logical request."},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-ProsoftPay-Timestamp","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Prosoftpay-Timestamp"}},{"name":"X-ProsoftPay-Nonce","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Prosoftpay-Nonce"}}],"responses":{"204":{"description":"No content -- the endpoint was deactivated (or already was)."},"401":{"description":"Merchant-API authentication failed: missing/malformed `Authorization`, `X-ProsoftPay-Timestamp`, or `X-ProsoftPay-Nonce` header (`auth_missing`, `auth_malformed`, `auth_incomplete`); unknown API key (`key_not_found`); a key issued before signature support existed (`key_needs_rotation`) or whose stored secret failed to decrypt (`key_decryption_failed`); a timestamp outside the ±5 minute tolerance (`timestamp_expired`) or unparseable (`invalid_timestamp`); a signature mismatch (`signature_invalid`); or a nonce already used within the tolerance window (`replay_detected`). See `docs/API_SPEC.md`'s Authentication section.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The API key is known but not active (`key_inactive`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Rate limit exceeded for this API key (`rate_limited`) -- a sliding 60-second window, see `app/core/rate_limit.py`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Request validation failed -- malformed input (`validation_error`), or a semantic validation failure raised by business logic (e.g. an unparseable `tenant_id` filter, code `invalid_tenant_id`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"The request body could not be parsed -- not valid UTF-8, or not valid JSON (`invalid_request`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No such webhook endpoint for this tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Idempotency-Key reused for a different webhook endpoint.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"MerchantHmac":[]}]}},"/v1/admin/transactions":{"get":{"tags":["admin"],"summary":"List transactions (admin)","description":"List transactions across the platform, paginated and filterable. `tenant_admin`/`tenant_viewer` callers are always scoped to their own tenant -- a `tenant_id` filter that doesn't match their own tenant is rejected with 403. `platform_admin` callers may pass an explicit `tenant_id` filter to scope the list to one tenant, or omit it for a genuinely cross-tenant view across every tenant.","operationId":"list_admin_transactions_v1_admin_transactions_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"Max transactions to return.","default":25,"title":"Limit"},"description":"Max transactions to return."},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Opaque cursor from a previous page.","title":"Cursor"},"description":"Opaque cursor from a previous page."},{"name":"state","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/PaymentState"},{"type":"null"}],"description":"Filter by transaction state.","title":"State"},"description":"Filter by transaction state."},{"name":"created_from","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Inclusive lower bound on `created_at`.","title":"Created From"},"description":"Inclusive lower bound on `created_at`."},{"name":"created_to","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Inclusive upper bound on `created_at`.","title":"Created To"},"description":"Inclusive upper bound on `created_at`."},{"name":"tenant_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter to one tenant's transactions. `platform_admin` only.","examples":["tnt_9c1e2b7a-1234-4a11-8e2d-abcdef012345"],"title":"Tenant Id"},"description":"Filter to one tenant's transactions. `platform_admin` only."},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"A page of transactions plus pagination metadata.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminTransactionListResponse"}}}},"401":{"description":"Admin-API authentication failed: missing/malformed `Authorization: Bearer <jwt>` header (`auth_missing`); the JWT is malformed (`jwt_malformed`), signed by an unrecognized key (`jwt_unknown_kid`), or fails RS256/audience/issuer validation against Auth0's JWKS (`jwt_invalid`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Caller's role couldn't be recognized (`admin_role_unrecognized`), a tenant-scoped caller has no tenant claim (`tenant_scope_missing`), or a `tenant_id` filter doesn't match the caller's own tenant (`tenant_mismatch`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Request validation failed -- malformed input (`validation_error`), or a semantic validation failure raised by business logic (e.g. an unparseable `tenant_id` filter, code `invalid_tenant_id`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"The `cursor` query parameter is malformed (`invalid_cursor`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"AdminBearer":[]}]}},"/v1/admin/transactions/{transaction_id}":{"get":{"tags":["admin"],"summary":"Retrieve a transaction (admin)","description":"Retrieve a single transaction by its `id`, including its full `TransactionEvent` history, `LedgerEntry` postings, and associated `WebhookDelivery` attempts. `tenant_admin`/`tenant_viewer` callers only see their own tenant's transactions -- a transaction belonging to another tenant returns 404, identical to a nonexistent id, so a caller can never learn a transaction exists in someone else's tenant from the error shape. `platform_admin` may retrieve any tenant's transaction.","operationId":"get_admin_transaction_v1_admin_transactions__transaction_id__get","parameters":[{"name":"transaction_id","in":"path","required":true,"schema":{"type":"string","title":"Transaction Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"The requested transaction, its event history, ledger postings, and webhook delivery attempts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminTransactionDetailResponse"}}}},"401":{"description":"Admin-API authentication failed: missing/malformed `Authorization: Bearer <jwt>` header (`auth_missing`); the JWT is malformed (`jwt_malformed`), signed by an unrecognized key (`jwt_unknown_kid`), or fails RS256/audience/issuer validation against Auth0's JWKS (`jwt_invalid`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Caller's role couldn't be recognized (`admin_role_unrecognized`), or a tenant-scoped caller (`tenant_admin`/`tenant_viewer`) has no tenant claim (`tenant_scope_missing`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Request validation failed -- malformed input (`validation_error`), or a semantic validation failure raised by business logic (e.g. an unparseable `tenant_id` filter, code `invalid_tenant_id`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No such transaction, or it belongs to another tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"AdminBearer":[]}]}},"/v1/admin/tenants":{"get":{"tags":["admin"],"summary":"List tenants (admin, platform_admin only)","description":"List all tenants on the platform, paginated and filterable by status. `platform_admin` only -- `tenant_admin`/`tenant_viewer` callers are rejected with 403, since this route is inherently cross-tenant. `Tenant` is the root of multi-tenancy and is not itself tenant-scoped, so this query needs no tenant-guard bypass.","operationId":"list_admin_tenants_v1_admin_tenants_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"Max tenants to return.","default":25,"title":"Limit"},"description":"Max tenants to return."},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Opaque cursor from a previous page.","title":"Cursor"},"description":"Opaque cursor from a previous page."},{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/TenantStatusOut"},{"type":"null"}],"description":"Filter by tenant status.","title":"Status"},"description":"Filter by tenant status."},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"A page of tenants plus pagination metadata.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminTenantListResponse"}}}},"401":{"description":"Admin-API authentication failed: missing/malformed `Authorization: Bearer <jwt>` header (`auth_missing`); the JWT is malformed (`jwt_malformed`), signed by an unrecognized key (`jwt_unknown_kid`), or fails RS256/audience/issuer validation against Auth0's JWKS (`jwt_invalid`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Caller's role couldn't be recognized (`admin_role_unrecognized`), a tenant-scoped caller has no tenant claim (`tenant_scope_missing`), or the caller's role is not `platform_admin` (`insufficient_admin_role`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Request validation failed -- malformed input (`validation_error`), or a semantic validation failure raised by business logic (e.g. an unparseable `tenant_id` filter, code `invalid_tenant_id`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"The `cursor` query parameter is malformed (`invalid_cursor`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"AdminBearer":[]}]}},"/v1/admin/tenants/{tenant_id}":{"get":{"tags":["admin"],"summary":"Retrieve a tenant (admin)","description":"Retrieve a single tenant by its `id` -- the tenant row itself, nothing computed (no transaction-volume/aggregate stats; no such endpoint exists this sprint, see `docs/ARCHITECTURE.md` §10). `platform_admin` may retrieve any tenant. `tenant_admin`/`tenant_viewer` may only retrieve their own tenant -- any other `tenant_id` returns 404, identical to a nonexistent id.","operationId":"get_admin_tenant_v1_admin_tenants__tenant_id__get","parameters":[{"name":"tenant_id","in":"path","required":true,"schema":{"type":"string","title":"Tenant Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"The requested tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminTenantDetailResponse"}}}},"401":{"description":"Admin-API authentication failed: missing/malformed `Authorization: Bearer <jwt>` header (`auth_missing`); the JWT is malformed (`jwt_malformed`), signed by an unrecognized key (`jwt_unknown_kid`), or fails RS256/audience/issuer validation against Auth0's JWKS (`jwt_invalid`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Caller's role couldn't be recognized (`admin_role_unrecognized`), or a tenant-scoped caller (`tenant_admin`/`tenant_viewer`) has no tenant claim (`tenant_scope_missing`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Request validation failed -- malformed input (`validation_error`), or a semantic validation failure raised by business logic (e.g. an unparseable `tenant_id` filter, code `invalid_tenant_id`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No such tenant, or it isn't the caller's own tenant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"AdminBearer":[]}]}},"/v1/admin/api-keys":{"get":{"tags":["admin"],"summary":"List API keys (admin)","description":"List API keys, paginated and filterable by status. Never returns `secret_hash`/`secret_encrypted` -- only `key_prefix`, `status`, `scopes`, `last_used_at`, and timestamps. `tenant_admin`/`tenant_viewer` callers are always scoped to their own tenant -- a `tenant_id` filter that doesn't match their own tenant is rejected with 403. `platform_admin` callers may pass an explicit `tenant_id` filter to scope the list to one tenant, or omit it for a genuinely cross-tenant view. Rotation/issuance/revocation are not part of this route -- see `docs/API_SPEC.md`'s scope note.","operationId":"list_admin_api_keys_v1_admin_api_keys_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"Max API keys to return.","default":25,"title":"Limit"},"description":"Max API keys to return."},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Opaque cursor from a previous page.","title":"Cursor"},"description":"Opaque cursor from a previous page."},{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiKeyStatusOut"},{"type":"null"}],"description":"Filter by API key status.","title":"Status"},"description":"Filter by API key status."},{"name":"tenant_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter to one tenant's API keys. `platform_admin` only.","examples":["tnt_9c1e2b7a-1234-4a11-8e2d-abcdef012345"],"title":"Tenant Id"},"description":"Filter to one tenant's API keys. `platform_admin` only."},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"A page of API keys plus pagination metadata.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminApiKeyListResponse"}}}},"401":{"description":"Admin-API authentication failed: missing/malformed `Authorization: Bearer <jwt>` header (`auth_missing`); the JWT is malformed (`jwt_malformed`), signed by an unrecognized key (`jwt_unknown_kid`), or fails RS256/audience/issuer validation against Auth0's JWKS (`jwt_invalid`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Caller's role couldn't be recognized (`admin_role_unrecognized`), a tenant-scoped caller has no tenant claim (`tenant_scope_missing`), or a `tenant_id` filter doesn't match the caller's own tenant (`tenant_mismatch`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Request validation failed -- malformed input (`validation_error`), or a semantic validation failure raised by business logic (e.g. an unparseable `tenant_id` filter, code `invalid_tenant_id`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"The `cursor` query parameter is malformed (`invalid_cursor`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"AdminBearer":[]}]}},"/v1/admin/webhooks/{webhook_endpoint_id}/deliveries":{"get":{"tags":["admin"],"summary":"List webhook delivery attempts (admin)","description":"List delivery attempts for a webhook endpoint, paginated and filterable by status. `tenant_admin`/`tenant_viewer` callers may only list deliveries for an endpoint belonging to their own tenant -- an endpoint belonging to another tenant returns 404, identical to a nonexistent id. `platform_admin` may list deliveries for any tenant's endpoint.","operationId":"list_admin_webhook_deliveries_v1_admin_webhooks__webhook_endpoint_id__deliveries_get","parameters":[{"name":"webhook_endpoint_id","in":"path","required":true,"schema":{"type":"string","title":"Webhook Endpoint Id"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"Max deliveries to return.","default":25,"title":"Limit"},"description":"Max deliveries to return."},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Opaque cursor from a previous page.","title":"Cursor"},"description":"Opaque cursor from a previous page."},{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/WebhookDeliveryStatusOut"},{"type":"null"}],"description":"Filter by delivery status.","title":"Status"},"description":"Filter by delivery status."},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"A page of webhook delivery attempts plus pagination metadata.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminWebhookDeliveryListResponse"}}}},"401":{"description":"Admin-API authentication failed: missing/malformed `Authorization: Bearer <jwt>` header (`auth_missing`); the JWT is malformed (`jwt_malformed`), signed by an unrecognized key (`jwt_unknown_kid`), or fails RS256/audience/issuer validation against Auth0's JWKS (`jwt_invalid`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Caller's role couldn't be recognized (`admin_role_unrecognized`), or a tenant-scoped caller (`tenant_admin`/`tenant_viewer`) has no tenant claim (`tenant_scope_missing`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Request validation failed -- malformed input (`validation_error`), or a semantic validation failure raised by business logic (e.g. an unparseable `tenant_id` filter, code `invalid_tenant_id`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No such webhook endpoint, or it isn't the caller's own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"The `cursor` query parameter is malformed (`invalid_cursor`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"AdminBearer":[]}]}}},"components":{"schemas":{"AdminApiKeyListResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/AdminApiKeySummary"},"type":"array","title":"Data","description":"This page's API keys."},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor","description":"Opaque cursor for the next page."},"has_more":{"type":"boolean","title":"Has More","description":"Whether another page exists after this one."}},"additionalProperties":false,"type":"object","required":["data","has_more"],"title":"AdminApiKeyListResponse"},"AdminApiKeySummary":{"properties":{"id":{"type":"string","title":"Id","description":"Opaque API key identifier: `ak_` followed by a UUID4.","examples":["ak_3fa85f64-5717-4562-b3fc-2c963f66afa6"]},"tenant_id":{"type":"string","title":"Tenant Id","description":"`tnt_<uuid>` -- the owning tenant.","examples":["tnt_9c1e2b7a-1234-4a11-8e2d-abcdef012345"]},"key_prefix":{"type":"string","title":"Key Prefix","description":"Public, non-secret prefix identifying this key (the `KeyId` in the merchant API's `Authorization` header).","examples":["rgp_live_abc123"]},"status":{"$ref":"#/components/schemas/ApiKeyStatusOut","description":"`active` or `revoked`."},"scopes":{"items":{"type":"string"},"type":"array","title":"Scopes","description":"Granted permission scopes."},"last_used_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Used At","description":"When this key last successfully authenticated a request."},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"When this key was issued, in UTC."}},"type":"object","required":["id","tenant_id","key_prefix","status","created_at"],"title":"AdminApiKeySummary"},"AdminTenantDetailResponse":{"properties":{"id":{"type":"string","title":"Id","description":"Opaque tenant identifier: `tnt_` followed by a UUID4.","examples":["tnt_9c1e2b7a-1234-4a11-8e2d-abcdef012345"]},"name":{"type":"string","title":"Name","description":"Tenant's display name.","examples":["Banque de Crédit de Bujumbura"]},"slug":{"type":"string","title":"Slug","description":"URL-safe unique identifier for this tenant.","examples":["bcb"]},"status":{"$ref":"#/components/schemas/TenantStatusOut","description":"`active` or `suspended`."},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"When this tenant was created, in UTC."}},"type":"object","required":["id","name","slug","status","created_at"],"title":"AdminTenantDetailResponse","description":"Deliberately does **not** include `Tenant.settings`\n(`app/models/tenant.py`): that column is an open, unstructured JSONB\nescape hatch with no allowlist, and nothing today stops a future entry\n(a provider API secret, a settlement account detail, anything) from\nlanding in it. Exposing it verbatim to every admin's browser was flagged\nas a latent leak in security review -- see `docs/API_SPEC.md`'s\n\"Admin API\" section for this decision. If the dashboard ever needs to\nsurface specific tenant settings, that's a new, explicitly-scoped ticket\nwith a narrow, named-field schema (e.g. `AdminTenantSettingsOut`), not a\n`dict[str, Any]` passthrough."},"AdminTenantListResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/AdminTenantSummary"},"type":"array","title":"Data","description":"This page's tenants."},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor","description":"Opaque cursor for the next page."},"has_more":{"type":"boolean","title":"Has More","description":"Whether another page exists after this one."}},"additionalProperties":false,"type":"object","required":["data","has_more"],"title":"AdminTenantListResponse"},"AdminTenantSummary":{"properties":{"id":{"type":"string","title":"Id","description":"Opaque tenant identifier: `tnt_` followed by a UUID4.","examples":["tnt_9c1e2b7a-1234-4a11-8e2d-abcdef012345"]},"name":{"type":"string","title":"Name","description":"Tenant's display name.","examples":["Banque de Crédit de Bujumbura"]},"slug":{"type":"string","title":"Slug","description":"URL-safe unique identifier for this tenant.","examples":["bcb"]},"status":{"$ref":"#/components/schemas/TenantStatusOut","description":"`active` or `suspended`."},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"When this tenant was created, in UTC."}},"type":"object","required":["id","name","slug","status","created_at"],"title":"AdminTenantSummary"},"AdminTransactionDetailResponse":{"properties":{"id":{"type":"string","title":"Id","description":"pay_<uuid> -- same scheme as the merchant API.","examples":["pay_3fa85f64-5717-4562-b3fc-2c963f66afa6"]},"tenant_id":{"type":"string","title":"Tenant Id","description":"`tnt_<uuid>` -- the owning tenant.","examples":["tnt_9c1e2b7a-1234-4a11-8e2d-abcdef012345"]},"state":{"$ref":"#/components/schemas/PaymentState","description":"Current position in the payment lifecycle."},"amount":{"type":"string","pattern":"^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$","title":"Amount","description":"Transaction amount.","examples":["150000.00"]},"currency":{"type":"string","title":"Currency","description":"ISO 4217 currency code.","examples":["BIF"]},"external_ref":{"type":"string","title":"External Ref","description":"Institution's own reference.","examples":["policy-2026-00432"]},"capture_mode":{"type":"string","enum":["automatic","manual"],"title":"Capture Mode","description":"The capture mode requested at creation."},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"When this transaction was created, in UTC."},"updated_at":{"type":"string","format":"date-time","title":"Updated At","description":"When this transaction last changed, in UTC."},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"Free-text description supplied at creation, if any."},"metadata":{"additionalProperties":{"type":"string"},"type":"object","title":"Metadata","description":"Institution-supplied key/value metadata."},"events":{"items":{"$ref":"#/components/schemas/TransactionEventOut"},"type":"array","title":"Events","description":"Full state-transition history, oldest first."},"ledger_entries":{"items":{"$ref":"#/components/schemas/LedgerEntryOut"},"type":"array","title":"Ledger Entries","description":"Every double-entry ledger posting for this transaction."},"webhook_deliveries":{"items":{"$ref":"#/components/schemas/AdminWebhookDeliveryOut"},"type":"array","title":"Webhook Deliveries","description":"Every webhook delivery attempt triggered by this transaction."}},"type":"object","required":["id","tenant_id","state","amount","currency","external_ref","capture_mode","created_at","updated_at","events","ledger_entries","webhook_deliveries"],"title":"AdminTransactionDetailResponse"},"AdminTransactionListResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/AdminTransactionSummary"},"type":"array","title":"Data","description":"This page's transactions."},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor","description":"Opaque cursor for the next page; `null` when `has_more` is false."},"has_more":{"type":"boolean","title":"Has More","description":"Whether another page exists after this one."}},"additionalProperties":false,"type":"object","required":["data","has_more"],"title":"AdminTransactionListResponse"},"AdminTransactionSummary":{"properties":{"id":{"type":"string","title":"Id","description":"pay_<uuid> -- same scheme as the merchant API.","examples":["pay_3fa85f64-5717-4562-b3fc-2c963f66afa6"]},"tenant_id":{"type":"string","title":"Tenant Id","description":"`tnt_<uuid>` -- the owning tenant.","examples":["tnt_9c1e2b7a-1234-4a11-8e2d-abcdef012345"]},"state":{"$ref":"#/components/schemas/PaymentState","description":"Current position in the payment lifecycle."},"amount":{"type":"string","pattern":"^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$","title":"Amount","description":"Transaction amount.","examples":["150000.00"]},"currency":{"type":"string","title":"Currency","description":"ISO 4217 currency code.","examples":["BIF"]},"external_ref":{"type":"string","title":"External Ref","description":"Institution's own reference.","examples":["policy-2026-00432"]},"capture_mode":{"type":"string","enum":["automatic","manual"],"title":"Capture Mode","description":"The capture mode requested at creation."},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"When this transaction was created, in UTC."},"updated_at":{"type":"string","format":"date-time","title":"Updated At","description":"When this transaction last changed, in UTC."}},"type":"object","required":["id","tenant_id","state","amount","currency","external_ref","capture_mode","created_at","updated_at"],"title":"AdminTransactionSummary"},"AdminWebhookDeliveryListResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/AdminWebhookDeliveryOut"},"type":"array","title":"Data","description":"This page's delivery attempts."},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor","description":"Opaque cursor for the next page."},"has_more":{"type":"boolean","title":"Has More","description":"Whether another page exists after this one."}},"additionalProperties":false,"type":"object","required":["data","has_more"],"title":"AdminWebhookDeliveryListResponse"},"AdminWebhookDeliveryOut":{"properties":{"id":{"type":"string","title":"Id","description":"Opaque delivery-attempt identifier: `whd_` followed by a UUID4.","examples":["whd_3fa85f64-5717-4562-b3fc-2c963f66afa6"]},"webhook_endpoint_id":{"type":"string","title":"Webhook Endpoint Id","description":"The endpoint this delivery was sent to.","examples":["wh_3fa85f64-5717-4562-b3fc-2c963f66afa6"]},"event_type":{"type":"string","title":"Event Type","description":"The event type this delivery carried.","examples":["payment.captured"]},"status":{"type":"string","title":"Status","description":"`pending`, `succeeded`, `failed` (mid-retry), or `exhausted` (all attempts used).","examples":["succeeded"]},"attempts":{"type":"integer","title":"Attempts","description":"Number of delivery attempts made so far."},"last_attempted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Attempted At","description":"When the most recent delivery attempt ran, if any."},"next_retry_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Next Retry At","description":"When the next retry is scheduled, if the delivery isn't finished."},"response_code":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Response Code","description":"HTTP status code from the receiving endpoint's last attempt, if any."},"response_body_excerpt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Response Body Excerpt","description":"Truncated response body from the receiving endpoint -- never the full body."},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"When this delivery attempt was recorded."}},"type":"object","required":["id","webhook_endpoint_id","event_type","status","attempts","created_at"],"title":"AdminWebhookDeliveryOut"},"ApiKeyStatusOut":{"type":"string","enum":["active","revoked"],"title":"ApiKeyStatusOut","description":"Mirrors `app/models/api_key.py::ApiKeyStatus`."},"Authorization":{"properties":{"authorized_at":{"type":"string","format":"date-time","title":"Authorized At","description":"When the bank connector authorized this payment, in UTC."},"authorization_code":{"type":"string","title":"Authorization Code","description":"Bank/connector-issued authorization code, for reconciliation.","examples":["AUTH-8823-DEF"]}},"type":"object","required":["authorized_at","authorization_code"],"title":"Authorization","description":"Populated once `PaymentResponse.state` reaches `authorized` (or beyond).\nAbsent (`null`) while a payment is still `initiated`, or if the bank\ndeclined it outright (`state=\"failed\"`)."},"Customer":{"properties":{"reference":{"type":"string","title":"Reference","description":"Institution's opaque customer reference.","examples":["cust_98214"]},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email","description":"Customer's email address, if known. Not validated as deliverable.","examples":["customer@example.bi"]}},"additionalProperties":false,"type":"object","required":["reference"],"title":"Customer"},"ErrorDetail":{"properties":{"type":{"type":"string","title":"Type","description":"Canonical error-type bucket. One of: `invalid_request`, `authentication_failed`, `permission_denied`, `not_found`, `conflict`, `validation_error`, `rate_limited`, `internal_error`, `service_unavailable` -- see `docs/API_SPEC.md`'s Errors section for the full HTTP-status mapping.","examples":["invalid_request"]},"code":{"type":"string","title":"Code","description":"Specific, machine-readable error code within `type` (lowercase snake_case), e.g. `key_inactive`, `idempotency_key_conflict`, `bank_timeout`. Stable for programmatic handling -- prefer switching on `code`, not `message`, which is human-readable and may change wording.","examples":["unsafe_webhook_url"]},"message":{"type":"string","title":"Message","description":"Human-readable explanation. Not guaranteed stable across releases.","examples":["Webhook URL resolves to a private or reserved address."]},"request_id":{"type":"string","title":"Request Id","description":"Correlates this error with server-side logs -- the same value as the `X-Request-Id` response header (`app/core/middleware.py`). A bare UUIDv4, not a prefixed/opaque resource id.","examples":["3fa85f64-5717-4562-b3fc-2c963f66afa6"]}},"additionalProperties":false,"type":"object","required":["type","code","message","request_id"],"title":"ErrorDetail"},"ErrorResponse":{"properties":{"error":{"$ref":"#/components/schemas/ErrorDetail","description":"The error's details."}},"additionalProperties":false,"type":"object","required":["error"],"title":"ErrorResponse","description":"The full JSON body of every Prosoft Pay error response."},"LedgerEntryOut":{"properties":{"id":{"type":"string","title":"Id","description":"Opaque ledger entry identifier: `le_` followed by a UUID4.","examples":["le_3fa85f64-5717-4562-b3fc-2c963f66afa6"]},"account":{"type":"string","title":"Account","description":"Ledger account this posting belongs to (free-text -- chart of accounts is not finalized, see `app/models/ledger_entry.py`).","examples":["bank_clearing"]},"direction":{"type":"string","enum":["debit","credit"],"title":"Direction","description":"Posting direction. Amount is always strictly positive; sign lives here."},"amount":{"type":"string","pattern":"^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$","title":"Amount","description":"Posting amount (always > 0).","examples":["150000.00"]},"currency":{"type":"string","title":"Currency","description":"ISO 4217 currency code.","examples":["BIF"]},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"When this posting was recorded, in UTC."}},"type":"object","required":["id","account","direction","amount","currency","created_at"],"title":"LedgerEntryOut"},"PaymentCreateRequest":{"properties":{"amount":{"anyOf":[{"type":"number","exclusiveMinimum":0.0},{"type":"string","pattern":"^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}],"title":"Amount","description":"Payment amount as a decimal string. Max 4 decimal places.","examples":["150000.00"]},"currency":{"type":"string","maxLength":3,"minLength":3,"title":"Currency","description":"ISO 4217 currency code.","examples":["BIF"]},"external_ref":{"type":"string","maxLength":128,"title":"External Ref","description":"Institution's reference for this payment (invoice, policy, order id).","examples":["policy-2026-00432"]},"description":{"anyOf":[{"type":"string","maxLength":500},{"type":"null"}],"title":"Description","description":"Free-text description, shown back verbatim in the response.","examples":["Insurance premium — Q3 2026"]},"customer":{"anyOf":[{"$ref":"#/components/schemas/Customer"},{"type":"null"}],"description":"Optional customer reference for this payment."},"metadata":{"additionalProperties":{"type":"string"},"type":"object","title":"Metadata","description":"Arbitrary institution-supplied key/value metadata (string values only), echoed back verbatim on every subsequent read of this payment. Never store PAN/CVV/track data here -- see CLAUDE.md non-negotiables.","examples":[{"policy_number":"POL-2026-00432","product":"motor_insurance"}]},"capture_mode":{"type":"string","enum":["automatic","manual"],"title":"Capture Mode","description":"`automatic` captures immediately after authorization; `manual` requires a separate capture call.","default":"automatic"}},"additionalProperties":false,"type":"object","required":["amount","currency","external_ref"],"title":"PaymentCreateRequest"},"PaymentListResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/PaymentResponse"},"type":"array","title":"Data","description":"This page's payments, most-recent-first."},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor","description":"Opaque cursor for the next page; omitted/`null` when `has_more` is false."},"has_more":{"type":"boolean","title":"Has More","description":"Whether another page exists after this one."}},"additionalProperties":false,"type":"object","required":["data","has_more"],"title":"PaymentListResponse","description":"Envelope for `GET /v1/payments`, per `docs/API_SPEC.md`'s\n\"Pagination\" section: cursor-based, `{data, next_cursor, has_more}`."},"PaymentResponse":{"properties":{"id":{"type":"string","title":"Id","description":"Opaque payment identifier: `pay_` followed by the payment's UUID4 (see `app/utils/ids.py`) -- not a ULID, despite the older, now-corrected example this field used to document.","examples":["pay_3fa85f64-5717-4562-b3fc-2c963f66afa6"]},"state":{"$ref":"#/components/schemas/PaymentState","description":"Current position in the payment lifecycle -- see `docs/API_SPEC.md`."},"amount":{"type":"string","pattern":"^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$","title":"Amount","description":"Same value the caller submitted.","examples":["150000.00"]},"currency":{"type":"string","title":"Currency","description":"ISO 4217 currency code.","examples":["BIF"]},"external_ref":{"type":"string","title":"External Ref","description":"Institution's own reference, echoed back verbatim.","examples":["policy-2026-00432"]},"capture_mode":{"type":"string","enum":["automatic","manual"],"title":"Capture Mode","description":"The capture mode requested at creation."},"authorization":{"anyOf":[{"$ref":"#/components/schemas/Authorization"},{"type":"null"}],"description":"Bank authorization details, once authorized."},"capture_error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Capture Error","description":"Populated only when `capture_mode=\"automatic\"` authorized this payment successfully but the immediate automatic capture attempt that follows failed (bank decline or connector timeout). The authorization itself is real and durable -- `state` will be `authorized`, not `captured`, and not rolled back to `initiated`. Retry completion via a manual capture.","examples":[null]},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"When this payment intent was created, in UTC."}},"type":"object","required":["id","state","amount","currency","external_ref","capture_mode","created_at"],"title":"PaymentResponse"},"PaymentState":{"type":"string","enum":["initiated","authorized","captured","settled","voided","refunded","failed"],"title":"PaymentState"},"PingResponse":{"properties":{"message":{"type":"string","title":"Message","description":"Always `pong` when the API is reachable.","examples":["pong"]}},"additionalProperties":false,"type":"object","required":["message"],"title":"PingResponse"},"TenantStatusOut":{"type":"string","enum":["active","suspended"],"title":"TenantStatusOut","description":"Mirrors `app/models/tenant.py::TenantStatus` -- see module docstring\nfor why this schema-layer enum doesn't import that one directly."},"TransactionEventOut":{"properties":{"id":{"type":"string","title":"Id","description":"Opaque event identifier: `txe_` followed by a UUID4.","examples":["txe_3fa85f64-5717-4562-b3fc-2c963f66afa6"]},"from_state":{"anyOf":[{"$ref":"#/components/schemas/PaymentState"},{"type":"null"}],"description":"State before this transition. `null` for the first event."},"to_state":{"$ref":"#/components/schemas/PaymentState","description":"State after this transition."},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata","description":"Service-internal transition metadata (e.g. bank response code). Never PAN/CVV/track data -- see CLAUDE.md non-negotiables."},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"When this transition was recorded, in UTC."}},"type":"object","required":["id","to_state","created_at"],"title":"TransactionEventOut"},"WebhookDeliveryStatusOut":{"type":"string","enum":["pending","succeeded","failed","exhausted"],"title":"WebhookDeliveryStatusOut","description":"Mirrors `app/models/webhook.py::WebhookDeliveryStatus`."},"WebhookEndpointCreateRequest":{"properties":{"url":{"type":"string","maxLength":2083,"minLength":1,"format":"uri","title":"Url","description":"HTTPS endpoint Prosoft Pay will POST signed event payloads to.","examples":["https://merchant.example.bi/webhooks/prosoftpay"]},"events":{"items":{"$ref":"#/components/schemas/WebhookEventType"},"type":"array","minItems":1,"title":"Events","description":"Event types this endpoint subscribes to.","examples":[["payment.authorized","payment.captured","payment.failed"]]}},"additionalProperties":false,"type":"object","required":["url","events"],"title":"WebhookEndpointCreateRequest"},"WebhookEndpointListResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/WebhookEndpointResponse"},"type":"array","title":"Data","description":"All of the authenticated tenant's webhook endpoints."}},"additionalProperties":false,"type":"object","required":["data"],"title":"WebhookEndpointListResponse"},"WebhookEndpointResponse":{"properties":{"id":{"type":"string","title":"Id","description":"Opaque endpoint identifier: `wh_` followed by a UUID4.","examples":["wh_3fa85f64-5717-4562-b3fc-2c963f66afa6"]},"url":{"type":"string","title":"Url","description":"The registered delivery URL.","examples":["https://merchant.example.bi/webhooks/prosoftpay"]},"events":{"items":{"type":"string"},"type":"array","title":"Events","description":"Event types this endpoint is subscribed to.","examples":[["payment.authorized","payment.captured"]]},"status":{"$ref":"#/components/schemas/WebhookEndpointStatusOut","description":"`active` (healthy), `degraded` (5+ consecutive fully-exhausted deliveries since the last success), or `disabled` (deactivated via `DELETE`)."},"consecutive_failures":{"type":"integer","title":"Consecutive Failures","description":"Consecutive fully-exhausted deliveries since the last success."},"secret":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Secret","description":"The plaintext signing secret. Only ever populated in the response to `POST /v1/webhooks` (creation) -- never returned by `GET`/`PATCH`/list, since it cannot be re-displayed safely once issued. Store it immediately; there is no 'reveal again' endpoint this sprint (rotate by deleting and recreating the endpoint).","examples":[null]},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"When this endpoint was registered, in UTC."}},"type":"object","required":["id","url","events","status","consecutive_failures","created_at"],"title":"WebhookEndpointResponse"},"WebhookEndpointStatusOut":{"type":"string","enum":["active","degraded","disabled"],"title":"WebhookEndpointStatusOut"},"WebhookEndpointUpdateRequest":{"properties":{"url":{"anyOf":[{"type":"string","maxLength":2083,"minLength":1,"format":"uri"},{"type":"null"}],"title":"Url","description":"New HTTPS endpoint. Omit to leave unchanged.","examples":["https://merchant.example.bi/webhooks/v2"]},"events":{"anyOf":[{"items":{"$ref":"#/components/schemas/WebhookEventType"},"type":"array","minItems":1},{"type":"null"}],"title":"Events","description":"New set of subscribed event types. Omit to leave unchanged.","examples":[["payment.captured","payment.refunded"]]}},"additionalProperties":false,"type":"object","title":"WebhookEndpointUpdateRequest","description":"`PATCH /v1/webhooks/{id}` — all fields optional; only provided fields\nare updated. `status` is intentionally not settable here: activating a\n`disabled` endpoint is a deliberate decision left to `POST` (create a\nnew one) or a future dedicated reactivation action, not folded silently\ninto a general-purpose field patch -- and DELETE is the only sanctioned\nway to move an endpoint to `disabled` this ticket."},"WebhookEventType":{"type":"string","enum":["payment.authorized","payment.captured","payment.voided","payment.failed","payment.refunded"],"title":"WebhookEventType"}},"securitySchemes":{"MerchantHmac":{"type":"apiKey","in":"header","name":"Authorization","description":"Merchant API. `Authorization: ProsoftPay-HMAC-SHA256 KeyId=<key_prefix>, Signature=<hex>` -- HMAC-SHA256 over the canonical request string. See docs/API_SPEC.md §Authentication."},"AdminBearer":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"Admin/dashboard API. Auth0-issued JWT, validated against the tenant's JWKS. Never accepted on merchant (HMAC) routes."}}},"tags":[{"name":"payments","description":"Merchant API: create, retrieve, and list payments. HMAC-signed."},{"name":"webhooks","description":"Merchant API: register and manage webhook endpoints. HMAC-signed."},{"name":"admin","description":"Dashboard/back-office API: read-only tenant, transaction, API key, and webhook-delivery views. Auth0 JWT, never merchant HMAC."},{"name":"meta","description":"Operational endpoints not scoped to any tenant."}]}