# BeanzPay generic SaaS API reference

This is the implementation reference for a new SaaS connector. It is a
provider-neutral companion to the sanitized OpenAPI and Postman files in
`integrations/saas/general/`. The reference describes the reviewed contract at
candidate runtime source commit `28959793176ed4894b39ce1210b88afef60188d3`
(reviewed 21 October 2026); the live discovery response is
authoritative when it advertises a newer version, capability, scope, limit, or
environment state.

The connector is server-to-server. A SaaS frontend must never receive a
Partner secret, a merchant Bearer, a webhook signing secret, an owner claim
token, or an operational continuation token. End merchants see the SaaS
platform's payment status and BeanzPay-neutral support messages. They do not
need to know which upstream processor, account, or internal identifier is used
behind BeanzPay.

Candidate revision: Partner discovery 1.4.0, Partner API 1.9.0 and Native API 1.5.0.
Deployment and financial acceptance require their own separately attributed
evidence; publication of this document does not establish production rollout.

## 1. Contract surfaces and environment model

Use configuration for all URLs. The values below are placeholders, not
deployment addresses:

```text
PARTNER_TEST_BASE_URL=https://api.example.invalid/api/partner
PARTNER_ADMIN_BASE_URL=https://api.example.invalid/api/partner/live
MERCHANT_BASE_URL=https://api.example.invalid/api/merchant/v1
```

The current public BeanzPay host is `https://beanzpay.com`; the standalone
Postman blank environment is prefilled with that HTTPS host while leaving all
credential values empty. A deployment that uses another approved host must
change its explicit allowlist and base-path checks together.

| Surface | Base path | Authentication | Role | Default status |
| --- | --- | --- | --- | --- |
| Partner Test | `/api/partner` | `X-Partner-Key` and `X-Partner-Secret` | discovery, onboarding, test administration, capability-gated test operations | conditional |
| Partner production administration | `/api/partner/live` | the same Partner pair, if production administration is enabled | production onboarding, claims, metadata, reports, commercial records, operational requests | conditional |
| Merchant Native Test | `/api/merchant/v1` | merchant/environment-bound Bearer | merchant payments and management | conditional |
| Merchant Native Live | `/api/merchant/v1` | merchant/environment-bound Bearer | approved live financial and management operations | conditional |

The Partner base path selects the Test or production onboarding contract.
The Native URL does not select its financial environment: a Bearer is bound to its merchant,
environment, scopes, and (when applicable) current live MID. The Partner
onboarding alias `production` and the Native financial alias `live` describe
different surfaces. Store the binding returned by discovery and account
metadata; do not infer it from a URL string.

The exact deployed sources are:

- Native OpenAPI: `docs/merchant-api/openapi.yaml` (version 1.5.0).
- Partner OpenAPI: `integrations/saas/postman/beanzpay-partner-openapi.yaml`
  (version 1.9.0).
- Native webhook contract: version 3.0.0.
- Native subscription contract: version 1.4.0.

The copies in this pack are sanitized for reusable distribution. A source
change can add a route without making the feature available to a new account;
read discovery and feature readiness at runtime before rendering a control.

## 2. Authentication and owner authorization

### Partner requests

Send the Partner pair only from a backend service:

```http
GET /api/partner/live/integration HTTP/1.1
Host: api.example.invalid
Accept: application/json
X-Partner-Key: ${PARTNER_KEY}
X-Partner-Secret: ${PARTNER_SECRET}
```

The key and secret are required together. Keep separate secret-manager entries
for Test and production administration if the deployment uses separate
credentials. Redact the two headers from request logs and tracing spans.

### Native requests

```http
GET /api/merchant/v1/account HTTP/1.1
Host: api.example.invalid
Accept: application/json
Authorization: Bearer ${MERCHANT_BEARER}
```

The Bearer fixes the merchant and environment. Do not accept a caller-supplied
merchant ID, MID, provider ID, or environment selector as authority. `GET
/account` and `GET /features` are the first readiness checks after issuance.

The current issuable scope vocabulary is:

```text
account:read
payments:create
payments:read
payments:reconcile
payments:manage
refunds:create
refunds:read
refunds:reconcile
transactions:read
transfers:read
webhooks:read
webhooks:manage
invoices:read
invoices:manage
subscriptions:read
subscriptions:manage
card_tokens:read
payments:authcap
```

Request only the smallest set needed by the connector. An authorization is
never expanded implicitly when a new feature is enabled. A new scope requires a
new explicit owner consent record. Scope vocabulary does not create a public
capture/void or general card-token payment endpoint.

Partner credentials currently have no public child-key or employee-scoped API;
discovery reports `scope_enforcement=not_supported_for_legacy_partner_credentials`.
The SaaS backend must enforce its own staff/store permissions. Native scopes are
financial delegation scopes, not Partner employee roles.

`GET /account` returns `credential.scopes`, `credential.id` and
`credential.expires_at`. `GET /features` separately returns `data.effective_scopes`;
do not treat that property as part of the account credential object. Partner
Native-credential metadata adds `authorization_id` and `active`, never the token.
Read `native-authorizations` for scopes, consent version/time, expiry and revoke.
An authorization `expires_at=null` has no time bound but remains revocable and
subject to ownership/readiness. A Native credential currently expires after
one calendar year (`P1Y`), with at most five active credentials per environment. Treat these as
separate lifecycles. Revoking, expiring or rotating a delegated credential, or
revoking its authorization, stops linked webhook delivery; endpoints/pending
attempts do not move to a new credential. Direct owner-created endpoints have
independent management and must be inspected/revoked explicitly on offboarding.

### Owner and existing-merchant journey

The Partner backend may create a new merchant application or link an existing
merchant, but owner consent remains a separate trust boundary:

1. Discover the Partner contract and save the returned version, environment,
   limits, capability states, and supported document types.
2. For a new merchant, submit a stable `external_reference`, required business
   data, and any server-generated commercial quote fields accepted by the
   current Partner contract. Never use a browser-supplied price as a trusted
   quote.
3. For an existing merchant, use the one-time owner-issued claim token with a
   stable external reference. If the response is lost, query the existing
   application; do not create another claim.
4. Ask the verified owner to authorize the exact environment and scope list.
   Owner continuation URLs are short-lived, contain no credential, and must be
   opened through the owner session.
5. Read the authorization metadata, then issue a merchant Bearer from the
   Partner administration flow. The Bearer is shown once in a confidential
   channel. Store it before acknowledging success.
6. On rotation, persist the new secret before disabling the old local record,
   test a non-financial read, and then remove the old value. The old credential
   fails closed after rotation or revocation; there is no general grace period.

### One-time secret operation recovery

`Idempotency-Key` is mandatory for Partner Native credential issue/rotate and
Native webhook create/rotate. Use 8–120 visible ASCII characters without spaces;
persist the exact key, canonical request, merchant, environment and authorization
before dispatch. These are local atomic database operations, not provider
financial writes. The first successful response alone contains the token/secret
plus `operation`. Save the secret to a confidential secret manager immediately.

While current actor/authorization/resource gates remain valid, exact completed replay returns flat HTTP409
`{type:"secret_already_delivered",message:...,errors:{operation:...}}`.
A different validated effective canonical body, kind, resource or authorization under the same key
returns `409 idempotency_conflict`. Neither response reveals the secret or
performs a second domain mutation. Credential issue is201, rotate200; webhook
create is201, rotate200. Their first successful objects remain flat.

Recover by the original header key, never a query key:

```http
GET /api/partner/live/merchants/{merchant}/native-credential-operations?environment=live
Idempotency-Key: credential-issue-synthetic-0001

GET {MERCHANT_BASE_URL}/webhook-operations
Idempotency-Key: webhook-create-synthetic-0001
```

The Partner lookup requires the explicit `test|live` query environment; the
Native lookup requires `webhooks:read` and uses the Bearer's merchant/environment
and delegation. The response is `{operation:{...}}` metadata only. Its twelve
fields are id, kind, merchant_id, partner_id, authorization_id, environment,
resource_id, status, result_type, result_id, completed_at and secret_retrievable.
Kinds are credential.issue, credential.rotate, webhook.create and webhook.rotate.
Status is completed, and secret_retrievable is false. A direct owner context may
have empty Partner/authorization bindings. Receipts have no TTL in this contract;
reserved is internal to the atomic transaction, with no exposed pending/failed
row. An exception before commit rolls the local transaction back completely.

A404 `record_not_found` is not authority for automatic replacement issuance.
After completion is known, a lost secret requires deliberate controlled rotation
of the known result_id with a new key and current authorization; preserve
isolation and persist the replacement secret. Escalate unresolved results.
Never rotate on each synchronization, retry409 automatically, or apply the
financial refund replay rule to these first-response-only secrets. The separate
secret-operations client demonstrates this boundary without automatic POST retry.

### Operation recovery authority and error shapes

The guarantee is no second mutation and no second secret reveal, not permanent
receipt access after revocation. Authentication or an authorization/resource
checked before the ledger may produce401/404 instead of409. The lookup itself
requires current actor/environment/delegation authority. A revoked Native grant
can prevent Bearer recovery; use the authorized operations/Partner follow-up
without inventing a broader read entitlement.

Canonical request hashing uses validated effective fields and sorted object
keys. Array order, including events, is preserved; unknown fields ignored by
validation do not change the request hash. Missing/invalid Idempotency-Key
currently yields Laravel422 {message,errors:{"Idempotency-Key":[...]}}. Operation
conflicts and record-not-found use flat {type,message,errors}; these differ from
Native financial error.code responses.

### Contract metadata and owner continuation

`GET /applications/{application}/contract` is available on both Partner surfaces.
It returns a flat application_id, merchant_id, environment, application_status
and contract. Contract is null before preparation; otherwise it contains id,
version, status, signed_at, sha256, signed_verified, signature_method=email_otp,
owner_auth_required=true, owner_view_url and owner_download_url. Reading it never
prepares a PDF or creates an event. SHA identifies the current unsigned PDF
before signing and the signed PDF afterwards. signed_verified checks actual
signature evidence and PDF integrity; it is independent from an application
status or legacy flag. No legal/fee/owner snapshot or PDF body is exposed.

The owner URLs require the correct verified-owner session. They are neither
anonymous presigned downloads nor Partner S2S access. Use the existing request:

```http
POST {PARTNER_LIVE_BASE_URL}/applications/{application}/owner-continuation
```

Send no body. A valid continuation retains expiry; an expired one renews for
48 hours with the same opaque UUID, application/merchant and stored return_url.
Do not resend onboarding to renew it. return_url, external_reference, merchant_id
and package_slug are prohibited422 inputs here. The flat response includes
owner_continuation_url, owner_continuation metadata and a next_action hint:
contract_signature, partner_authorization or merchant_onboarding. Actual owner
OTP/signature/consent gates still apply, and no automatic return redirect is
promised. Cross-Partner/environment/owner mismatches return404.

Contract preparation remains gated by completed review, setup-fee handling and
provider approval for the application. Payment alone does not promise an
immediately ready contract. Owner signature, Native delegation, selected
environment/scopes and payment readiness remain separate credential gates.

`contract.ready` and `contract.signed` are separate durable lifecycle_v1
milestones reserved with their PDF/signature commits. They do not depend on
merchant.activated, and no ACTIVE flag/backfill or GET synthesizes a signature.
Deployment does not backfill an event for every historical contract; read the
current contract endpoint to recover actual state, including an older contract.
Publication recovery runs through the existing outbox scheduler; HTTP delivery
retains its current retry policy and stable event ID. Reconcile a missing event
with the scoped contract read and operations follow-up; the read does not replay
an event. Signing still does not establish financial readiness or delegation.

### Primary production S2S onboarding

The primary automated path is `POST /api/partner/live/merchants` from the SaaS
backend. Hosted Connect can be a discovered fallback, but it does not replace
the stable application reference or the owner authorization required before a
financial credential is issued.

The current create contract requires these fields (the exact conditional
business fields still come from discovery):

```http
POST {PARTNER_ADMIN_BASE_URL}/merchants
X-Partner-Key: ${PARTNER_KEY}
X-Partner-Secret: ${PARTNER_SECRET}
Idempotency-Key: onboard-merchant-00042
Content-Type: application/json

{
  "external_reference": "platform-merchant-00042",
  "package_slug": "<allowed-package-from-discovery>",
  "merchant_type": "company",
  "commercial_registration": "<registration-number-from-secure-form>",
  "business_name": "Example Merchant",
  "email": "owner@example.test",
  "mobile": "05XXXXXXXX",
  "website": "https://merchant.example.invalid",
  "iban": "SA<22-digits>",
  "owner_name": "Verified Owner",
  "owner_national_id": "<owner-id-from-secure-form>"
}
```

This is a structural example: placeholders are not valid submission data.
Company creation requires `commercial_registration`; freelancer creation requires
`freelancer_doc_number` under the discovered type contract.

`external_reference` is the SaaS-owned stable binding. The deprecated
`external_id` alias may be used only when it has the identical value. Select a
`package_slug` from the authenticated package catalog; never trust a local
hardcoded list. Send `settlement_schedule` and conditional activity fields
only when discovery requires them. `retail_price_minor` is a server-validated
minor-unit input only for a partner contract that advertises it. Do not send
`commercial_pricing_quote` or a quote calculated in the browser. BeanzPay
captures its own immutable commercial snapshot at acceptance and does not
reprice an accepted application during activation.

The response can include `application_id`, `merchant_id`,
`external_reference`, and an opaque `owner_continuation_url`. Persist the
identifiers before acknowledging success. The verified owner must complete the
returned portal OTP/email verification, sign the displayed terms, and grant
the exact environment and scope list before financial credential issuance.
Treat a route name that resembles an identity workflow as a BeanzPay portal
step; it is not a claim of government identity verification.

The create request also accepts optional `document_fields`, an object keyed by
the dynamic document slot (`company_address`, `owner_id`, `owner_address_2`,
and so on). Each value is a metadata object using the discovered field names,
not a filename or document body. Partial known fields are retained; supplied
values must satisfy their existing validation rules. Unknown slots/field names,
out-of-range owner/signatory slots, and duplicate base/`_1` aliases are422.
For example, `"document_fields":{"company_address":{"city":"Riyadh","country":"SA"}}`
preserves a partial company address without asserting it is review-complete.
Generic `address` is not a substitute for full `company_address` metadata.

Initial fields remain bound to the original creation request: retry the same
`external_reference` with the same metadata. Changing them in a create replay
does not update an existing application. An upload for the same exact slot
retains initial fields when no newer metadata exists; a first non-identity
upload may supplement that snapshot. Newer document fields take precedence.
Identity replacements never combine new details with old ID/birthdate values.
Initial metadata for a requirement with `requires_upload=true` never counts
as a file receipt. No owner consent, internal approval, or financial activation
is inferred from supplied initial metadata.

Read the dynamic application lifecycle instead of inventing states:

```text
GET  {PARTNER_ADMIN_BASE_URL}/merchants/{merchant}/application
GET  {PARTNER_ADMIN_BASE_URL}/merchants/{merchant}/environment
GET  {PARTNER_ADMIN_BASE_URL}/merchants/{merchant}/capabilities
GET  {PARTNER_ADMIN_BASE_URL}/applications/{application}
GET  {PARTNER_ADMIN_BASE_URL}/applications/{application}/documents
POST {PARTNER_ADMIN_BASE_URL}/applications/{application}/documents
POST {PARTNER_ADMIN_BASE_URL}/applications/{application}/submit
GET  {PARTNER_ADMIN_BASE_URL}/applications/{application}/timeline
POST {PARTNER_ADMIN_BASE_URL}/applications/{application}/cancel
```

The contract also exposes document batch/field updates, modification
requests, confirmation resend, and the owner verification actions under the
same dynamic application identifier. Store the returned `status` and
`next_actions`; do not mark a merchant active merely because an application
was created.

`GET /applications/{application}/documents` is a read-only local diagnostic.
The existing `uploaded`, `all_required_complete` and
`all_required_uploads_complete` flags retain their receipt/presence meaning;
they do not prove that document fields are valid or approved. Each entry also
returns `metadata_complete`, `missing_fields` (field names only),
`metadata_source` and `metadata_receipt_mismatch`. The summary adds
`all_required_metadata_complete`, `missing_required_metadata` keyed by document
slot, `stale_required_document_receipts`, `readiness_scope` and `review_ready`.
`review_ready` requires all required file uploads and valid required
metadata. Optional slots do not block it. Every required owner_id needs a real
front image plus complete identity fields; signatory_id remains fields-only
when its returned requirement has requires_upload=false.
The summary always includes owner_identity_files_available: null before
submitted_at or without a linked merchant, otherwise true only when each required
owner slot has an eligible current non-superseded row and its private file exists.
A missing physical owner file makes this false and review_ready false, even if
uploaded and old receipt totals remain true. Review readiness additionally
requires all required uploads, valid metadata and no physical-availability false.
This check is read-only and does not approve or restore a document.

Before submission, the readiness scope is `application_receipt_snapshot`.
After `submitted_at` exists it is
`application_receipts_with_matched_current_metadata`: current non-superseded
merchant-document corrections count only for the same semantic slot and the
same file path or receipt/revision. A changed file path invalidates the old
receipt even if receipt metadata was retained. Missing, replaced or ambiguous
current slots fail closed and appear in `stale_required_document_receipts`.
`metadata_source` distinguishes `application_receipt_snapshot`,
`matched_current_merchant_document`, `unmatched_current_merchant_document` and
`missing_current_merchant_document`. A stale slot can have an empty
`missing_fields` list because its fields are populated but its receipt is no
longer current. The GET never resubmits or repairs documents, changes lifecycle
state, or promotes `review_status=received` to approval. `review_ready=true`
is local completeness, not provider approval, owner consent, or activation.
An admin correction to a matched fields-only signatory can make it review-ready
while the legacy `all_required_complete` flag still describes its partial
application snapshot; use the new readiness fields for this distinction.

For an existing merchant, `POST {PARTNER_ADMIN_BASE_URL}/merchants/claim`
accepts a one-time owner-issued claim token and the stable external reference.
A successful claim links history to the Partner; it does not create a second
merchant, issue a Native Bearer, call an upstream service, or add retroactive
commercial charges. After a lost response, read or replay the same idempotent
claim; never create a replacement claim.

### Document discovery, multipart and owner continuation

Initial discovery selects the type with `merchant_type`, not `account_type`.
The response property remains `account_type`; a missing recognized query type
uses the freelancer default. For an existing application, the authoritative
slots, `requires_upload`, metadata and readiness come from
`GET /applications/{application}/documents`, not an unrelated generic discovery.

`POST /applications/{application}/documents` requires multipart `type` and
`file`. Send fields as `fields[holder]`, `fields[iban]`, etc., not a JSON string
in a field named `fields`. PDF/JPEG/PNG are accepted up to 2048 KiB with real
content validation. Multipart field values are strings up to 255 characters,
subject to stricter metadata rules. Do not force a multipart Content-Type header:
cURL/Postman must generate its boundary. The Arabic guide contains full synthetic
bank/address cURL examples. Bank fields are `holder` and `iban`; address fields
are `name`, `street`, `district`, `building_number`, `postal_code`, `city`,
`country` (ISO-2), with optional `secondary_number`. Use the exact discovered slot.

`PATCH /applications/{application}/documents/{documentType}/fields` is a JSON
identity-only path, with `fields.id`, `fields.mobile`, `fields.date_of_birth`
required and age at least 18. It does not upload a file or update bank/address
metadata. From Partner API 1.9.0, every required owner_id slot additionally
requires one actual front identity image, with requires_upload=true. Metadata
alone no longer satisfies an owner slot. Upload it to the existing application
with type=owner_id (or its exact numbered slot), file, fields[id], fields[mobile]
and fields[date_of_birth] as Multipart bracket parts. No separate back image is
collected by the Partner contract. signatory_id remains fields-only when the
current requirements say requires_upload=false. Uploading an image never extracts
its text automatically. The public example uses synthetic inputs only.
Edits are limited to the documented preparation/modification states, and do not
require prior owner consent. There is a narrow paid-completion window for
required owner slots whose image is missing: the application must be in paid
processing, the bound owned merchant must be paid and provider-linked in an
eligible repair state, and the recorded original setup payment must exist.
Single/batch upload and owner identity PATCH are permitted only for those
missing required slots. Complete valid metadata is required before storing
the file; if fields are omitted the current canonical complete fields are
retained. Other paid document edits and deletion remain closed.

After all required files and metadata are complete, the existing application
automatically returns to BeanzPay local review; upload returns the flat
application_status, with under_review when that transition occurs. Do not
call submit again. No provider POST, registration or new invoice is triggered.
The verified owner can also use the merchant document portal. An administrator
reviews the new image before provider submission, retaining other approved
documents and original identifiers. State rejection is distinct from a 422
field error. Re-read readiness and status after upload; a receipt is not
provider acceptance, owner Native consent or financial activation.

Owner continuation currently lasts 48 hours; an expired URL returns 410.
Renew the current application with bodyless
`POST /applications/{application}/owner-continuation`. Only an expired link is
extended, retaining its opaque UUID, application/merchant and stored return_url;
a valid link is unchanged. Do not resend merchant creation, metadata or
return_url to this route. Resume by the original identifiers through reads and
Partner lifecycle. An automatic return redirect is not promised by this contract.

Official merchant terms are `https://beanzpay.com/terms`; the fixed current
version is `https://beanzpay.com/terms/1.0`, published/effective 1 September 2026.
BeanzPay preserves the contract snapshot/hash and immutable email-OTP signing
evidence. No public create-time `terms_accepted` checkbox substitutes for it.
`/portal/nafath` names that email-OTP signing step, not government identity
verification. Merchant terms, Native delegation and the Partner commercial
agreement remain separate consents.

## 3. Discovery and capability checks

Run production discovery first for a production tenant; use the Test base only
for an explicitly separate Test contract:

```http
GET {PARTNER_ADMIN_BASE_URL}/integration
GET {PARTNER_ADMIN_BASE_URL}/packages
GET {PARTNER_ADMIN_BASE_URL}/documents/required?merchant_type=company&owners_count=1&signatory=owner&signatory_count=1
# Test only:
GET {PARTNER_TEST_BASE_URL}/integration
```

After a Native credential exists:

```http
GET {MERCHANT_BASE_URL}/account
GET {MERCHANT_BASE_URL}/features
```

Treat the feature state as a contract, not as a suggestion:

- `supported`: the route is available subject to normal authentication and
  ownership checks;
- `conditional`: the route exists but needs a profile, owner consent, MID,
  readiness, or an explicit capability;
- `read_only`: the response is a BeanzPay-owned projection and a normal GET
  does not promise a live upstream inquiry;
- `manual`: the operation creates a durable review/request record and an
  approved operator process must complete it;
- `unsupported`: do not build a required product path around the route.

Cache discovery only for the documented TTL. Keep the version and environment
that produced each cached capability. If a later response removes a scope or
feature, disable the affected control and preserve pending operations for
recovery.

## 4. Idempotency and request ledger

Every write connector should create a local request row before sending a POST.
The row should contain the tenant, BeanzPay merchant, environment, route,
idempotency key, canonical request body hash, business reference, amount and
currency snapshot where applicable, attempt state, and last safe response.

`Idempotency-Key` uses visible ASCII bytes (`0x21`–`0x7e`, with no spaces) and
currently has a length range of 8 to 120 characters. The key is scoped to the authenticated merchant and
environment. A connector must reject its own reuse of a key with a different
canonical body before calling BeanzPay.

The state machine is deliberately conservative:

```text
prepared -> submitting -> confirmed
                         -> rejected
                         -> unknown
                         -> requires_reconciliation
```

Persist the original body bytes or an equivalent canonical representation. If
the network fails after submission, the result is unknown until the same
operation is recovered. A retry may replay the exact request with the exact
key where the endpoint contract permits it. It must never generate a new key,
reference, amount, or financial operation to escape an unknown result.

`409` or `503` with `payment_outcome_unknown`, `refund_outcome_unknown`, or
`native_outcome_unknown` is an unresolved outcome. A `422` definitive rejection
means the submitted request was rejected before a successful operation was
confirmed; correct the request and create a genuinely new attempt. Do not
classify an unknown response as a failure that can be silently retried.

For a financial POST, a transport failure, timeout, HTTP `408`, `429`, any
`5xx`, or a `2xx` response that is missing or fails the documented response
schema is also `unknown` unless a trusted reconciliation result proves a
definitive outcome. Preserve the exact idempotency key and canonical request
body, recover with that same key or a scoped read, and never replace the
operation automatically. A well-formed definitive `4xx` may be corrected as
a new attempt only after the original request is known to have been rejected.

## 5. Native payments

### Create

`POST /payments` requires the Bearer and `Idempotency-Key`:

```http
POST {MERCHANT_BASE_URL}/payments
Idempotency-Key: pay-2026-000042
Content-Type: application/json

{
  "amount": 12500,
  "currency": "SAR",
  "reference": "order-2026-000042",
  "customer": {
    "first_name": "Example",
    "last_name": "Customer",
    "email": "customer@example.test",
    "phone_number": "+966500000000"
  },
  "expires_in": 1800
}
```

`amount` is a positive integer in minor units. The reference is stable for
one business order. The body is limited to 65,536 bytes. Checkout options are
optional; a supplied origin must match the registered merchant origin and a
return URL must be HTTPS on that origin with no userinfo, fragment, query, or
custom port.

The successful response exposes BeanzPay-local identifiers, amount, currency,
reference, status, expiry, and safe checkout URLs. It does not expose a
provider identifier, card secret, raw card data, or Bearer.

### Recovery and reads

Use `GET /payments` with the documented status/date/reference filters or
`GET /payments/{payment}` for local state. A normal read is not an immediate
upstream inquiry. If a create response is lost, recover by the same
idempotency key or the immutable business reference as allowed by the response
contract. A reference lookup can itself remain `409 payment_outcome_unknown`;
that reserves the reference without proving that a payment exists.

`POST /payments/{payment}/reconcile` accepts only `{}`. Provider identifiers
are never caller-supplied. Reconciliation is currently a gated live inquiry;
the Test route may be registered but unavailable. A pending or unknown result
remains unresolved.

## 6. Native refunds

Refunds are child operations of a confirmed payment. Use:

```http
POST {MERCHANT_BASE_URL}/payments/{payment}/refund
Idempotency-Key: refund-2026-000042-01
Content-Type: application/json

{
  "refund_reference": "refund-order-2026-000042-01",
  "amount": 2500,
  "reason": "customer_request"
}
```

Omit `amount` for a full refund of the remaining refundable amount. Include a
minor-unit integer for a partial refund. The server snapshots the accepted
amount and currency while reserving the child, so later parent changes do not
reprice an existing child.

`refund_reference` is the canonical child reference. The legacy body field
`reference` is accepted only as an alias and is normalized to the same logical
fingerprint. If both are present they must be identical; a mismatch returns
`422` before any upstream submission. Existing idempotency records are replayed
when the canonical and legacy forms resolve to the same request.

Do not confuse the legacy `reference` query parameter on `GET /refunds` with
the body alias: in the list route it identifies the parent payment. Use
`refund_reference` for a child reference and `idempotency_key` for child
recovery. Filters also include `payment_id`, `payment_reference`, and `status`.

The child statuses are:

```text
submitting | pending_callback | refunded | requires_reconciliation | failed
```

Only `refunded` is terminal financial success. `submitting`,
`pending_callback`, and `requires_reconciliation` must remain visible and
must not trigger a second financial POST. `POST /refunds/{refund}/reconcile`
is a scoped inquiry/recovery operation; reconciling the parent payment alone
does not decide the child. A provider operation ID, if eventually exposed as a
safe local field, remains server-owned and is never accepted from the caller.

`POST /refunds/{refund}/reconcile` requires `refunds:reconcile`, JSON `{}`,
and no caller-supplied provider IDs. Automatic reconciliation runs every five
minutes, up to 50 eligible bound operations per run, subject to Live/inquiry
readiness. It cannot recover an unbound provider operation by guessing.
Inspect `provider_operation_bound`, `last_provider_verified_at` and
`last_provider_verification_source`; reconcile adds `checked_at`,
`requires_operator_review`, `support_reference` and `next_action`. Missing
binding requires BeanzPay operations with the original child ID, never a
replacement refund. See the complete synthetic response/recovery fixture.

The server reserves amounts atomically against cumulative refunds; do not rely
on a stale remaining balance for concurrent partial requests. Platform-recorded
fees do not prove provider actual fees. Refund windows, real fees and rejection
rules remain account/method/contract dependent; do not promise a fee or duration
not evidenced for the account.

## 7. Native webhooks

The Native webhook contract is version 3.0.0. The receiver verifies the exact
raw request bytes, not a re-serialized JSON object:

```text
signed_message = X-BeanzPay-Timestamp + "." + raw_body
expected = "sha256=" + hex(HMAC-SHA256(endpoint_secret, signed_message))
```

Require an integer timestamp within the documented freshness window and a
lowercase hexadecimal signature. Require all three event identity values to
match:

```text
X-BeanzPay-Webhook-ID == X-BeanzPay-Event-ID == body.event_id
body.occurred_at == body.timestamp
```

The signed request headers are `X-BeanzPay-Signature` and
`X-BeanzPay-Timestamp`; routing headers also include `X-BeanzPay-Event`,
`X-BeanzPay-Contract-Version`, and `X-BeanzPay-Environment`. Do not substitute
similarly named application headers from another webhook contract.

Verify the event merchant, environment, amount, currency, and reference
against the tenant's immutable order/refund record before applying any balance
or fulfilment change. `payment.refunded.data.amount` is the child refund
amount, not necessarily the original payment amount.

The supported event names are `payment.paid`, `payment.failed`,
`payment.refunded`, and `payment.voided`. Deduplicate by merchant, environment,
event ID, and event type in a durable ledger. A duplicate must return the
previous result without applying a second business transition.

There is one endpoint per merchant/environment. Create and rotate return the
signing secret once; list and revoke return metadata only. The receiver should
respond within 30 seconds. BeanzPay has five automatic attempts with backoff
`[60, 300, 900, 3600]` seconds and a bounded manual replay policy. Rotation or
revocation invalidates the old binding; do not keep accepting the old secret.

`POST /webhooks` with a new operation key, even for the same URL, replaces the endpoint and secret;
it is not an idempotent configuration sync. There is no independent secret
expiry or old-secret grace period in this baseline. Pending deliveries do not
move automatically to the new binding. Manual replay uses a separate key and
reason, at most three times with a 30-second cooldown. Native financial records
are protected `financial_evidence`; no fixed number of retention days is set at
creation. A Partner-log cleanup period is not the Native retention policy.

Partner lifecycle webhook delivery is a separate Partner contract. Apply its
signature schema and event history rules from the Partner discovery response;
`lifecycle_v1` covers merchant/catalog events, not customer financial evidence.
The candidate lifecycle list includes contract.ready and contract.signed independently
from activation; signed raw examples are in partner-contract-events-fixtures.json.
Historical payment/refund enum entries do not add them to the current account.
Application setup-fee events also remain distinct. In particular,
do not use the Native secret or Native event identity rules interchangeably.

## 8. Native management and subscriptions

The Native surface includes local account/features reads, subscription plan
management, enrollment, subscription actions, card metadata, transaction
reads, and operation recovery. Use only BeanzPay UUID handles returned by the
API. Provider IDs, raw card tokens, client secrets, and callback hints are not
valid caller input.

The first subscription enrollment uses a 3DS initiation followed by an
authenticated inquiry. A callback hint alone is not financial proof. The
server must bind the verified first transaction and exactly one subscription.
Unknown plan/enrollment/subscription/card writes return an unknown outcome and
are recovered with the original key or operation ID. The connector must not
submit a replacement mutation.

The operation recovery route accepts the original idempotency key and scopes
the lookup to merchant, environment, and current MID. It performs no provider
write. Keep a recovery screen for an operator when the state remains unknown.

Subscription enrollment is conditional on the merchant/environment profile,
MOTO/3DS and actual readiness. Full callback lifecycle support remains incomplete;
authenticated owned inquiry confirms the first enrollment. This product is not
a provider-to-merchant subscription or a general raw-token payment API.

## 9. Customer invoices

Native customer invoices are merchant-branded payment documents tied to a
Native hosted intention. Create/list/show/document/send/cancel routes are
available only when the relevant invoice scopes and readiness are present.
Create requires amount, currency, and customer data; item totals, when sent,
must exactly equal the amount. A document is not a tax invoice, a bank
statement, a provider invoice, or evidence that money settled. An unknown
invoice create is recovered with the same key or scoped lookup; do not create a
second payment intention.

Invoice send uses `{}` and a persisted key with BeanzPay's configured sender;
merchant contact in the body does not guarantee a Reply-To header. Cancel only
disables local checkout: it is not refund/void, and a later verified payment
still wins. The merchant system issues its own tax invoice. Payment links also
have list/detail/QR/cancel routes under the documented scopes and readiness.

## 10. Partner operations and Apple Pay artifact workflow

Partner production administration exposes durable operational requests for
capability activation, payment-method change, account lifecycle, fee change,
support, and Apple Pay artifact work. A request stores a local audit record and
metadata-only outbox entry. It does not call an upstream service, send a
customer email, register a domain, or make a financial change.

Create under `POST /merchants/{merchant}/operational-requests`:

```json
{
  "request_type": "apple_pay_activation",
  "service_key": "apple_pay",
  "action": "activate",
  "reason": "Activate the registered merchant domain",
  "details": {"domain":"shop.example.com"}
}
```

Apple Pay actions are `activate` or `verify_installation`, with `details.domain`
required. BeanzPay operations manually contacts the upstream provider and records
a verified result. The SaaS reads `.../{request}/apple-pay/artifact`, downloads
`.../artifact/download`, compares SHA-256, installs the artifact automatically
at `/.well-known/apple-developer-merchantid-domain-association` on the correct
public HTTPS domain without redirects/login, then acknowledges via
`.../{request}/apple-pay/installation`. Artifact matching does not prove provider
registration or activate every merchant. Track account/domain/environment readiness.

Payment-method requests use `payment_method_change`, a discovered method
`service_key`, and `enable|disable`. Provider-account requests use
`provider_account_lifecycle`, `service_key=account`, `disable|reactivate`.
The configured target is complete execution in three business days. The current
clock starts at record creation (`requested_at`) and uses the snapshotted Riyadh
calendar (Sunday–Thursday, configured holidays excluded) to return `due_at`.
Preserve `sla.configured`, `business_days`, `calendar`, `due_at`, `overdue`,
`late_finished`, `escalation_required` and `provider_sla_guarantee`. Do not restart
the clock at later acceptance or auto-approve an overdue request. This operational
target is not a provider time guarantee. `api_effective` can remain false even
after `provider_confirmed` if no verified adapter exists.

Local immediate payment-creation suspension/resumption is separate:
`controls/payment-creation`, `.../suspend`, `.../resume` with a reason. It does
not cancel old checkouts, subscriptions, pending payments or the provider account.

All operational requests use the documented `request_type`, `reason`, optional
`service_key`, `action`, `details`, and `Idempotency-Key`. Reusing a key with a
different body returns a conflict. Persist the request and outbox state before
showing the platform user a submitted status.

## 11. Commercial catalog, invoices, and statements

The catalog is a discovery input, not a hardcoded pricing table. BeanzPay
returns the partner-specific package quote and captures the accepted quote as
an immutable snapshot. A catalog refresh affects future quotes; it cannot
reprice an accepted application or activation.

The generic contract defines no share, waiver, subsidy, discount, tax rate, or
payment deadline. Read the signed commercial terms and the quote metadata for
the specific Partner. Keep commercial ledger entries separate from payment
transactions and provider settlements. A statement or CSV is a read-only
commercial document; it is not a bank balance, payout instruction, tax invoice,
or provider transaction export. Do not offer a balance or settlement feature
unless a new official endpoint and capability are published.

Package discounts concern setup/package fees, not customer transaction
coupons. The server enforces the allowed base-price ceiling. Only when advertised for that Partner, a fixed-base
share policy can produce a Partner debit; an approved discount-cost waiver can
instead compute the Partner percentage on actual pre-tax sale proceeds. Rates,
waivers and self-account exemptions are Partner-specific quote/contract settings,
not defaults copied from another Partner. A fully confirmed setup-fee refund
reverses all shares; a request/unknown/partial result is not a full reversal.
Partner API does not grant unrestricted setup-fee refund authority. For Partners with this ledger policy enabled, monthly
commercial statements currently use the seventh Gregorian day (not a connector-configurable day);
marking one paid does not transfer funds. A self merchant account is separate
from the Partner administration identity and any fee waiver must be approved.

### Explicit capability boundaries

Balances and official settlements/items/exports have no verified public REST
adapter in this baseline. Neither an Accept key nor a transaction report proves
available/pending funds. Payout creation, bank CRUD and bulk transfers are also
unavailable; `/transfers` is read-only. Public capture/void, generic token payment,
merchant-to-merchant transfers, provider-to-merchant subscriptions and customer
transaction coupons are outside the current contract. Do not substitute refund,
payout or a package discount for another product. Any manual statement alternative
requires an agreed source, sample, cadence and reconciliation identifiers first.
See `CAPABILITY_MATRIX.json` and `IMPROVEMENT_ROADMAP.md` for status and closure.

## 12. Errors, limits, retries, and observability

Log request IDs, route, local tenant, environment, operation ID, status, and
error code. Redact authorization, secrets, full customer payloads, webhook
signatures, and payment instruments. Do not copy a provider response or name
into an end-merchant message.

Common categories are:

| HTTP | Meaning | Connector action |
| --- | --- | --- |
| `400` | malformed request or invalid JSON | fix serialization; do not retry unchanged |
| `401` | missing/invalid credential | stop and inspect secret binding |
| `403` | owner, scope, merchant, or capability boundary | request authorization or disable feature |
| `404` | unknown local resource | confirm tenant/environment and local mapping |
| `409` | idempotency conflict, unknown outcome, or state conflict | use the returned code; recover same operation, never replace it |
| `413` | body too large | reduce the request within the documented limit |
| `422` | semantic/definitive rejection | correct the request; use a new key only for a new attempt |
| `429` | rate limit | honor `Retry-After`; preserve the original key |
| `500` | server error | do not assume no submission; recover by key/status |
| `503` | unavailable or unknown outcome | inspect the error code and recover; no replacement POST |

Use exponential backoff only for safe reads or an exact replay explicitly
allowed by the endpoint contract. Bound the number of client attempts and put
unresolved operations in an operator queue. Add metrics for unknown outcomes,
duplicate webhooks, rejected alias conflicts, scope denials, and stale
discovery caches.

## 13. Versioning and acceptance checklist

Version the connector against the discovery response, not just the repository
copy. Before enabling a new feature, verify:

- the expected Partner and Native versions and environment aliases;
- the merchant ID, environment, scopes, and capability state from authenticated
  metadata;
- a Test profile or Live approval exists where the feature is conditional;
- the request ledger persists keys, body fingerprints, references, and amount
  snapshots before writes;
- a lost response recovers without a second financial POST;
- full and partial refunds are child records and their unknown states stay
  unresolved;
- canonical `refund_reference` and the legacy body alias behave identically,
  while conflicting aliases fail before submission;
- Native webhook raw-body signatures, event identity, timestamp, amount,
  currency, merchant, environment, and deduplication checks are tested;
- operation and invoice recovery is tenant/environment scoped;
- operational and Apple Pay requests are represented as manual workflows;
- commercial rates are read from approved quotes and never hardcoded;
- end-merchant UI has no provider name, secret, internal ID, or raw exception;
- monitoring and support runbooks cover 401/403, 409/503, 429, webhook replay,
  key rotation, and operator reconciliation.

Record test evidence with the BeanzPay environment and contract version. A
healthy route or a successful synthetic request does not prove Live financial
UAT, merchant acceptance, external activation, or a signed commercial
agreement.

## Exact wire identifiers

Public schema property names, required lists, enums and constants retain their
actual wire values. A historical property such as `paymob_approved_at` or
`paymob_mutated` is a protocol identifier, not customer-facing branding. Map
these fields to BeanzPay-neutral labels in the merchant UI; never rename the
wire field in the connector. Descriptive wording may be provider-neutral, but
it does not introduce an alias that the server does not implement.

Initial document metadata is bounded to 128 slots, 20 optional repeated slots per type, and 2000 characters per value; stricter field-specific limits still apply. Owner/signatory slots cannot exceed the declared counts.

## Acceptance and escalation handoff

Keep documentation review, connector implementation, deployment, account
activation and joint financial UAT as separate gates. Test same-ID onboarding
and owner-link renewal; company/freelancer metadata, required owner front images and identity fields;
replaced receipts; cross-store/merchant/environment denial; consent, credential
rotation/revoke; successful/failed/late/unknown payment and deduplication; signed,
tampered, stale and repeated webhooks; full/partial/concurrent/unknown child
refunds; operational due dates and Apple Pay readiness; and original gateway/
account ownership for reading/refunding payments after switching providers.

Record who ran each test, version/environment/account, references and confirmed
outcome. BeanzPay's local tests or healthy HTTP routes are not joint financial
acceptance. Agree a window, technical/operations owners, private escalation
channel and response times before UAT. Preserve unresolved operations and stop
replacement writes until trusted reconciliation or documented operator handling.

For document incidents provide time/timezone, request ID, application UUID,
slot/type, status, rejected field names, file size and MIME only. Do not include
customer/bank values, files or credentials. Historical rejection causes that
were not retained must remain unknown instead of being inferred from current
state. Diagnose the existing application without replacement registration.
