From 279f1c067c8fb40a0cb04c7356bc32ea7e82f4c6 Mon Sep 17 00:00:00 2001 From: Marco Casaglia Date: Wed, 8 Apr 2026 10:07:23 +0200 Subject: [PATCH] docs(payment): update locked payment details and examples in documentation --- docs/payment_webhook.md | 4 + docs/request_to_pay.md | 69 +++++++++++++---- openapi.json | 166 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 218 insertions(+), 21 deletions(-) diff --git a/docs/payment_webhook.md b/docs/payment_webhook.md index 7c696db..446d7ea 100644 --- a/docs/payment_webhook.md +++ b/docs/payment_webhook.md @@ -6,10 +6,14 @@ FlowPay delivers real-time, server-to-server notifications whenever a payment re There is a single, stable event for Request-to-Pay: `payment.status_change`. The current state is provided in the `status` field and reuses the API enum `PaymentRequestStatus` (`created`, `inProgress`, `authorized`, `rejected`, `onHold`, `locked`, `forwarded`, `refunded`, `deleted`). If the change originates from a specific checkout attempt, the event includes a `sessionId`. Multiple attempts may occur during a request’s lifecycle, and events may be delivered more than once; your handler must therefore be idempotent and tolerant of out-of-order delivery within the same `requestId`. +For locked payments, deliveries may also include an optional `lockedDetails` object. When present, it contains the locked-payment metadata needed to continue the flow, such as reconciliation status, automatic-release state, and the dedicated technical payment method to use for an early release. + ## Request Details Deliveries use HTTPS POST with a JSON payload encoded in UTF‑8. The request carries headers that establish identity, integrity and replay protection. In particular, `X-FlowPay-Event-Id` uniquely identifies the delivery, `X-FlowPay-Event-Type` is always `payment.status_change`, `X-FlowPay-Timestamp` contains Unix epoch seconds, `X-FlowPay-Signature` holds a detached Ed25519 signature, `X-FlowPay-Key-Id` identifies the public key to use for verification, and `X-FlowPay-Retry-Count` indicates the attempt number starting at zero. Any HTTP 2xx response acknowledges the event. +In locked flows, treat `lockedDetails` as optional enrichment: use it when available, but always be prepared to recover the authoritative state with `GET /payment-requests/{requestId}`. + ## Security and Verification Every delivery is signed by FlowPay with a platform private key. The signature covers the exact bytes of the HTTP body prefixed by the timestamp. Specifically, construct `payload = "{timestamp}.{rawBody}"` and verify the Base64URL‑encoded Ed25519 signature from `X-FlowPay-Signature` using the public key indicated by `X-FlowPay-Key-Id`. diff --git a/docs/request_to_pay.md b/docs/request_to_pay.md index 61799b3..f599050 100644 --- a/docs/request_to_pay.md +++ b/docs/request_to_pay.md @@ -110,15 +110,15 @@ What it enables: mass invoice runs, bill aggregation, marketplace or platform se State evolution: authorization is single from the user’s perspective; settlement is mediated. After authorized, funds always land on the Technical Account, are processed immediately, and re-sent to beneficiaries. The request may appear onHold briefly while dispatch allocates amounts, then moves to forwarded. Subsequent reversals are reflected as refunded and are applied proportionally to the original allocation map. -## Conditional payment +## Locked payment -When outcomes depend on a later verification (delivery, inspection, return window), use Locked payments. You specify a `lockedUntil` date; after a successful checkout the funds are held in FlowPay’s technical account. Before the date, you can decide to release to the payee or refund the user. If you take no action by expiry, the platform automatically refunds the payer. +When outcomes depend on a later verification (delivery, inspection, return window, milestone approval), use Locked payments. You specify a `lockedUntil` date; after a successful checkout the funds are held on FlowPay’s technical perimeter and attributed to a dedicated technical wallet for that operation. Before the date, the partner may release the funds to the beneficiary or refund the payer. If no earlier decision is taken, FlowPay automatically releases the funds to the beneficiary at `lockedUntil`. -![](https://mermaid.ink/img/eyJjb2RlIjoic2VxdWVuY2VEaWFncmFtXG4gIGF1dG9udW1iZXJcbiAgcGFydGljaXBhbnQgVXNlclxuICBwYXJ0aWNpcGFudCBBUEkgYXMgRmxvd1BheSBBUElcbiAgcGFydGljaXBhbnQgQXBwIGFzIFlvdXIgQmFja29mZmljZVxuICBVc2VyLT4-QVBJOiBQYXlzIGF0IGNoZWNrb3V0IChzdWNjZWVkZWQpXG4gIEFQSS0tPj5BcHA6IENhbGxiYWNrIChzdWNjZWVkZWQsIGxvY2tlZClcbiAgTm90ZSBvdmVyIEFQSTogRnVuZHMgaGVsZCB1bnRpbCBsb2NrZWRVbnRpbFxuICBBcHAtPj5BUEk6IFJlbGVhc2UgdG8gcGF5ZWUgT1IgY3JlYXRlIHJlZnVuZFxuICBBUEktLT4-VXNlcjogQXV0b-KAkXJlZnVuZCBpZiBubyBkZWNpc2lvbiBieSBsb2NrZWRVbnRpbFxuIn0=) +![](https://mermaid.ink/img/eyJjb2RlIjoic2VxdWVuY2VEaWFncmFtXG4gIGF1dG9udW1iZXJcbiAgcGFydGljaXBhbnQgVXNlciBhcyBVc2VyXG4gIHBhcnRpY2lwYW50IEFQSSBhcyBGbG93UGF5IEFQSVxuICBwYXJ0aWNpcGFudCBBcHAgYXMgWW91ciBCYWNrb2ZmaWNlXG4gIEFwcC0-PkFQSTogUE9TVCAvcGF5bWVudC1yZXF1ZXN0cyAobG9ja2VkVW50aWwpXG4gIEFQSS0tPj5BcHA6IDIwMSB7IHJlcXVlc3RJZCwgbGluayB9XG4gIEFwcC0tPj5Vc2VyOiBSZWRpcmVjdCB0byBob3N0ZWQgY2hlY2tvdXRcbiAgVXNlci0-PkFQSTogQXV0aG9yaXplIGluaXRpYWwgcGF5bWVudFxuICBBUEktLT4-QXBwOiBDYWxsYmFjayAoYXV0aG9yaXplZCwgbG9ja2VkKVxuICBOb3RlIG92ZXIgQVBJOiBGdW5kcyBoZWxkIG9uIGRlZGljYXRlZCB0ZWNobmljYWwgd2FsbGV0IHVudGlsIGxvY2tlZFVudGlsXG4gIGFsdCBFYXJseSByZWxlYXNlXG4gICAgQXBwLT4-QVBJOiBQT1NUIC9jaGFyZ2VzIHdpdGggbG9ja2VkIHRva2VuSWRcbiAgICBBUEktLT4-QXBwOiBDYWxsYmFjayAoZm9yd2FyZGVkKVxuICBlbHNlIFJlZnVuZFxuICAgIEFwcC0-PkFQSTogUE9TVCAvcmVmdW5kc1xuICAgIEFQSS0tPj5BcHA6IENhbGxiYWNrIChyZWZ1bmRlZClcbiAgZWxzZSBObyBhY3Rpb24gYnkgZXhwaXJ5XG4gICAgQVBJLS0-PkFwcDogQ2FsbGJhY2sgKGZvcndhcmRlZCBhdCBsb2NrZWRVbnRpbClcbiAgZW5kIn0) -What it enables: escrow‑like experiences, milestone‑based projects, dispute/return windows with automatic safety fallback. +What it enables: escrow‑like experiences, milestone‑based projects, dispute/return windows, and any flow where the partner needs immediate authorization but delayed availability to the beneficiary. -State evolution: after a successful checkout the session is authorized and the request enters locked until either a release instructs forwarding to the payee or the lock expires and a refund is performed. If no release occurs, expiry leads to refunded. Additional user attempts do not alter an existing lock for already authorized funds. +State evolution: after a successful checkout the session is authorized, may pass briefly through `onHold` while funds are reconciled, and then enters `locked`. From that state the partner may release early to the beneficiary or refund the payer. If no explicit action is taken, expiry leads to `forwarded` through automatic release. Additional user attempts do not alter funds that are already locked. ## Split payment @@ -170,9 +170,9 @@ This section explains the lifecycle of a payment request as defined in the Simpl - authorized: provider confirms a positive outcome for the operation. - rejected: provider rejects/cancels the operation. - onHold: funds are on the FlowPay technical account (TA) awaiting dispatch rules. -- locked: waiting for partner’s explicit unlock to dispatch funds. -- forwarded: funds dispatched to the beneficiaries. -- refunded: full refund executed for the entire transaction. +- locked: funds have been reconciled on the dedicated technical wallet and are waiting for partner release, refund, or automatic release at `lockedUntil`. +- forwarded: funds dispatched to the beneficiaries, including automatic release at `lockedUntil` for locked payments. +- refunded: refund executed for the transaction; in locked flows this is the explicit alternative final outcome to release. - deleted: request removed by the partner (only if not in progress and not paid). Final states are: deleted, forwarded, rejected, refunded. @@ -274,13 +274,13 @@ Notes: ## Locked payment — example -- Goal: escrow‑like behavior. Funds are held in FlowPay’s Technical Account until `lockedUntil`. -- Data model: add `lockedUntil` (ISO 8601) at creation time. -- Lifecycle: on `succeeded`, funds are held. Before expiry you may release to the payee (per enabled business rules) or refund the payer (`POST /refunds`). At expiry, automatic refund occurs if no action was taken. +- Goal: escrow‑like behavior with optional early release and deterministic automatic release at expiry. +- Data model: add `lockedUntil` (ISO 8601) at creation time. The beneficiary must be identified before authorization; final payout coordinates may already be known at creation time or completed later before release. +- Lifecycle: after successful collection, funds are reconciled and the request enters `locked`. Before expiry you may retrieve the dedicated locked payment method and release the funds through `POST /charges`, or refund the payer through `POST /refunds`. If no explicit action is taken, FlowPay automatically releases the funds to the beneficiary at `lockedUntil`. ### How to enable Locked payment -Add `lockedUntil` (ISO 8601). After a `succeeded` session, funds are held until that date. Before expiry, either instruct a release (per partner configuration) or create a refund via the API. +Add `lockedUntil` (ISO 8601). After reconciliation, use `GET /payment-requests/{requestId}` or the webhook payload to retrieve `lockedDetails.lockedPaymentMethod.id`. Before expiry, either release the funds with `POST /charges` or refund the payer with `POST /refunds`. ```bash BASE_URL="https://api.sandbox.flowpay.it/v2/" @@ -290,13 +290,46 @@ LOCK_UNTIL=$(date -u -v+3d +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -d "+3 d curl -sS -X POST "$BASE_URL/payment-requests" \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ - -d "{\n \"payer\": { \"phone\": \"+39 333 1234567\" },\n \"title\": \"Escrow order #L-2001\",\n \"description\": \"Locked until verification\",\n \"remittanceInformation\": \"L-2001\",\n \"amount\": 59.00,\n \"currency\": \"EUR\",\n \"redirectUrl\": \"https://merchant.example.com/return\",\n \"callbackUrl\": \"https://merchant.example.com/api/payment/callback\",\n \"payee\": { \"name\": \"ACME Vendor\", \"iban\": \"IT60X0542811101000000123456\" },\n \"lockedUntil\": \"$LOCK_UNTIL\"\n }" + -d '{ + "payer": "+39 333 1234567", + "title": "Escrow order #L-2001", + "description": "Locked until verification", + "remittanceInformation": "L-2001", + "amount": 59.00, + "currency": "EUR", + "redirectUrl": "https://merchant.example.com/return", + "callbackUrl": "https://merchant.example.com/api/payment/callback", + "payee": { "name": "ACME Vendor", "iban": "IT60X0542811101000000123456" }, + "lockedUntil": "'"$LOCK_UNTIL"'" + }' +``` + +Early release example: + +```bash +REQUEST_ID="..." # from create response or callback + +LOCKED_TOKEN_ID=$(curl -sS -X GET "$BASE_URL/payment-requests/$REQUEST_ID" \ + -H "X-API-Key: $API_KEY" | jq -r '.lockedDetails.lockedPaymentMethod.id') + +curl -sS -X POST "$BASE_URL/charges" \ + -H "Content-Type: application/json" \ + -H "X-API-Key: $API_KEY" \ + -d '{ + "tokenId": "'"$LOCKED_TOKEN_ID"'", + "payee": { + "name": "ACME Vendor", + "iban": "IT60X0542811101000000123456" + }, + "remittanceInformation": "L-2001 RELEASE" + }' ``` -Refund example (per session): +Refund example (alternative final outcome): ```bash -SESSION_ID="..." # from sessions or callback +SESSION_ID="..." # locked collection session id from sessions or callback + curl -sS -X POST "$BASE_URL/refunds" \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ @@ -307,6 +340,12 @@ curl -sS -X POST "$BASE_URL/refunds" \ }' ``` +Notes: + +- Locked payments are all-or-nothing: the release amount is implicit in the locked token and matches the original locked amount; refunds are documented for the full amount only. +- If the final payee coordinates were not already available, provide them in the `payee` field of the locked release payload before expiry. +- Treat `callbackUrl` as the primary orchestration signal and use `GET /payment-requests/{requestId}` as the recovery path if a webhook is missed. + ## Bulk payments — example - Goal: let a user pay many targets with a single SCA (single checkout), reducing friction. diff --git a/openapi.json b/openapi.json index d8b5460..7d1c467 100644 --- a/openapi.json +++ b/openapi.json @@ -265,8 +265,8 @@ } }, "post": { - "summary": "Create a new payment request", - "description": "Endpoint to create a payment request between two parties, specifying payer and payee details.", + "summary": "Create a charge", + "description": "Creates a charge using a tokenized payment method. This endpoint is also used to release funds from a locked payment by charging the dedicated technical payment method of type `locked`. In that case the amount is implicit in the locked token and equals the original locked amount.", "operationId": "createCharge", "tags": [ "Charge" @@ -277,7 +277,7 @@ } ], "requestBody": { - "description": "Object containing debtor, payee and payment details.", + "description": "Object containing charge details or, for locked flows, the release details for a dedicated technical payment method.", "required": true, "content": { "application/json": { @@ -287,7 +287,37 @@ "tokenId": { "type": "string", "format": "uuid", - "description": "ID of the tokenized payment method to use for the charge." + "description": "ID of the tokenized payment method to use for the charge. For locked flows, this is the dedicated technical payment method exposed once the initial collection has been reconciled." + }, + "payee": { + "description": "Information about the payment payee. For locked-payment release, use this field only when the final payout destination was not already available at creation time.", + "oneOf": [ + { + "$ref": "#/components/schemas/PhoneNumber" + }, + { + "type": "string", + "format": "uuid", + "description": "UUID of the payee customer." + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the payee." + }, + "iban": { + "description": "IBAN of the payee's account.", + "$ref": "#/components/schemas/IBAN" + } + }, + "required": [ + "name", + "iban" + ] + } + ] } }, "required": [ @@ -1136,7 +1166,7 @@ "lockedUntil": { "type": "string", "format": "date-time", - "description": "If set, the funds for this payment are routed to a FlowPay technical account until the specified date. By that date it is possible to release the funds to the payee or refund the payer. If funds remain at expiry, they are automatically refunded to the payer. See the \"Chained/locked flows\" section for details." + "description": "If set, the request is treated as a locked payment. Funds are routed to FlowPay's technical perimeter until the specified date. Before that date the partner may release the funds to the beneficiary or refund the payer. If no earlier decision is taken, the funds are automatically released to the beneficiary at expiry. See the Locked Payment section for details." }, "savePaymentMethod": { "type": "boolean", @@ -2762,6 +2792,10 @@ "previousStatus": { "$ref": "#/components/schemas/PaymentRequestStatus", "description": "Previous status, when available." + }, + "lockedDetails": { + "$ref": "#/components/schemas/LockedPaymentDetails", + "description": "Locked-payment metadata, present when the request is in or near the locked lifecycle phase." } }, "required": [ @@ -3535,6 +3569,11 @@ "PaymentRequest": { "type": "object", "properties": { + "lockedUntil": { + "type": "string", + "format": "date-time", + "description": "If set, the request is a locked payment. Funds are held on FlowPay's technical perimeter until this timestamp. Before expiry the partner may release or refund the full amount; if no earlier decision is taken, FlowPay automatically releases funds to the beneficiary at `lockedUntil`." + }, "paymentRequestId": { "type": "string", "format": "uuid", @@ -3695,6 +3734,10 @@ "format": "date-time", "description": "Payment due date." }, + "lockedDetails": { + "$ref": "#/components/schemas/LockedPaymentDetails", + "description": "Additional technical details for locked payments, including automatic release state and the dedicated technical payment method once available." + }, "refundId": { "type": "string", "format": "uuid", @@ -3751,7 +3794,118 @@ "refunded", "deleted" ], - "description": "Status of the payment request (Simplified Flow).\n\n- created: request created and visible to the user.\n- inProgress: user starts a session and authenticates with the provider.\n- authorized: provider confirms a positive outcome for the operation.\n- rejected: provider rejects/cancels the operation.\n- onHold: funds are on the FlowPay technical account (TA) awaiting dispatch rules.\n- locked: waiting for partner’s explicit unlock to dispatch funds.\n- forwarded: funds dispatched to the beneficiaries.\n- refunded: full refund executed for the entire transaction.\n- deleted: request removed by the partner (only if not in progress and not paid).\n\nFinal states: deleted, forwarded, rejected, refunded." + "description": "Status of the payment request (Simplified Flow).\n\n- created: request created and visible to the user.\n- inProgress: user starts a session and authenticates with the provider.\n- authorized: provider confirms a positive outcome for the operation.\n- rejected: provider rejects/cancels the operation.\n- onHold: funds are on the FlowPay technical account (TA) awaiting dispatch rules.\n- locked: funds have been reconciled on the dedicated technical wallet and are waiting for partner release, refund, or automatic release at `lockedUntil`.\n- forwarded: funds dispatched to the beneficiaries, including automatic release at `lockedUntil` for locked payments.\n- refunded: refund executed for the transaction. In locked flows this is the explicit alternative final outcome to release.\n- deleted: request removed by the partner (only if not in progress and not paid).\n\nFinal states: deleted, forwarded, rejected, refunded." + }, + "AutomaticRelease": { + "type": "object", + "description": "State of the default automatic release prepared for a locked payment.", + "properties": { + "scheduledFor": { + "type": "string", + "format": "date-time", + "description": "Timestamp at which FlowPay automatically releases funds if no earlier release or refund is executed." + }, + "status": { + "type": "string", + "enum": [ + "scheduled", + "cancelled", + "executed", + "failed" + ], + "description": "Lifecycle state of the automatic release." + } + }, + "required": [ + "scheduledFor", + "status" + ] + }, + "LockedTechnicalPaymentMethod": { + "type": "object", + "description": "Dedicated technical payment method generated for a locked payment after the initial collection has been reconciled.", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Identifier to use as `tokenId` when releasing a locked payment through `/charges`." + }, + "type": { + "type": "string", + "enum": [ + "locked" + ], + "description": "Always `locked` for this technical payment method." + }, + "enabled": { + "type": "boolean", + "description": "Whether the locked payment method can currently be used for an early release." + }, + "amount": { + "type": "number", + "format": "decimal", + "minimum": 0.01, + "description": "Full amount bound to the locked operation." + }, + "currency": { + "type": "string", + "description": "Currency of the locked amount." + }, + "beneficiaryId": { + "type": "string", + "format": "uuid", + "description": "Beneficiary bound to the locked operation." + } + }, + "required": [ + "id", + "type", + "enabled", + "amount", + "currency", + "beneficiaryId" + ] + }, + "LockedPaymentDetails": { + "type": "object", + "description": "Technical and operational details specific to locked payments.", + "properties": { + "fundsOwner": { + "type": "string", + "enum": [ + "payer" + ], + "description": "During the locked phase, funds remain attributable to the original payer." + }, + "reconciliationStatus": { + "type": "string", + "enum": [ + "pending", + "reconciled" + ], + "description": "Status of the incoming collection reconciliation." + }, + "payoutReadiness": { + "type": "string", + "enum": [ + "ready", + "beneficiaryPayoutMissing" + ], + "description": "Whether the final payout coordinates are already available for release." + }, + "automaticRelease": { + "$ref": "#/components/schemas/AutomaticRelease" + }, + "lockedPaymentMethod": { + "$ref": "#/components/schemas/LockedTechnicalPaymentMethod" + } + }, + "required": [ + "fundsOwner", + "reconciliationStatus", + "payoutReadiness", + "automaticRelease" + ] }, "PaymentSessions": { "type": "object",