Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .cspell/custom-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,11 @@ zapatillas
recoverably
Honeycrisp
unrepresentable
Pix
pix
Mercadopago
mercadopago
Pago
boleto
copia
vencimento
176 changes: 176 additions & 0 deletions docs/specification/payment-render-artifact.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
<!--
Copyright 2026 UCP Authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->

# Payment Render Artifact Extension

* **Capability Name:** `com.mercadopago.shopping.render_artifact`
* **Schema:** `https://ucp.dev/schemas/shopping/payment_render_artifact.json`

@khegde-ppro khegde-ppro Aug 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. The render artifact design is clean and the render/trust contract is well specified.

One structural concern worth discussing: source/schemas/shopping/payment_render_artifact.json currently sits in the same flat directory as first-party core schemas like checkout.json, order.json, and payment.json. Since this is explicitly a vendor-namespaced capability (com.mercadopago.*), it would help consumers immediately distinguish stable core contracts from optional vendor extensions if these were kept in a separate directory.

Something like:
source/schemas/shopping/extensions/com.mercadopago/payment_render_artifact.json docs/specification/extensions/com.mercadopago/payment-render-artifact.md

This also matters for the pattern going forward to accommodate similar use cases for other handlers like PPRO, establishing a clear extensions/ home now avoids multiple vendor handlers landing alongside core schemas.
Worth aligning on the directory structure before the pattern solidifies.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strong +1 — worth settling before it solidifies. This is the first external-vendor namespace in the repo (com.mercadopago.*), so there's no extensions/ precedent yet, and I'd rather we agree the layout once than have each handler improvise.

Proposed starting point, mirroring your suggestion:

source/schemas/shopping/extensions/<vendor>/<name>.json
docs/specification/extensions/<vendor>/<name>.md

Mechanically this shifts the schema $id, the relative $refs (order.json../../order.json, ../common/...), the doc macro path, and the mkdocs nav/llmstxt entries — all fine, just wants doing consistently.

Since this sets a repo-wide convention for every vendor handler (yours included), @igrigorik / @raginpirate — could you say where these should live? Once it's confirmed I'll migrate both this PR and #719 (com.mercadopago.shopping.digital_delivery, same flat-dir situation) to it in one go.


## Overview

The Payment Render Artifact extension lets a Business complete an **in-session,
out-of-band payment method** — such as **Pix** in Brazil — without redirecting
the buyer or handing off a `continue_url`. When the buyer chooses such a method,
the Business surfaces an **inert display artifact** (a QR image, a
copy-and-paste code, and/or hosted instructions) as an outstanding Action on the
checkout. The Platform renders it, the buyer pays in their bank app, and the
**same** Complete Checkout operation resolves once payment is confirmed.

This is a vendor-namespaced capability stacked on the generic
[Actions](overview.md#actions) primitive. The **capability** is
`com.mercadopago.shopping.render_artifact`; it declares the **Action type**
`com.mercadopago.payment.render_artifact` and the shape of its `config`. Keeping
the capability and the Action type distinct lets the capability declare further
Action types later without overloading a single name.

**Key features:**

* Display Pix (and equivalent QR/code methods) natively — no iframe, no redirect
* Works across surfaces: web, native app, and voice (a `code` fallback exists
when no screen can show an image)
* Payment resolves in-session by polling Get Checkout — the Platform never
re-drives Complete
* Strict render/trust contract: display data only, inert fields, single
allowlisted loadable field

**Dependencies:**

* Checkout Capability
* The [Actions](overview.md#actions) primitive on Checkout

## Discovery

Businesses advertise this extension in their profile, extending the Checkout
capability:

<!-- ucp:example schema=profile def=business_schema extract=$.ucp.capabilities target=$.ucp.capabilities -->
```json
{
"ucp": {
"version": "{{ ucp_version }}",
"capabilities": {
"com.mercadopago.shopping.render_artifact": [
{
"version": "{{ ucp_version }}",
"extends": ["dev.ucp.shopping.checkout"],
"spec": "https://ucp.dev/{{ ucp_version }}/specification/payment-render-artifact",
"schema": "https://ucp.dev/{{ ucp_version }}/schemas/shopping/payment_render_artifact.json"
}
]
}
}
}
```

## Schema

When this extension is active, the checkout `actions` map MAY carry outstanding
`com.mercadopago.payment.render_artifact` instances. Each instance is a common Action
instance (`id`, optional `config`) whose `config` is the display artifact below.

### Payment Render Artifact Config

{{ extension_schema_fields('payment_render_artifact.json#/$defs/config', 'payment_render_artifact') }}

At least one of `code`, `image`, or `instructions_url` MUST be present so the
Platform always has something renderable.

## Render and Trust Contract

The artifact carries **public display data only** and is designed to be safe to
render on any surface. Implementations MUST honor the following:

* **No secrets transit the artifact.** It never contains a PAN, CVV, OTP,
token, credential, or any other sensitive value — only data already meant to
be shown to the buyer.
* **`image` is inert.** It is rendered as a static image only. The Platform MUST
NOT execute, interpret, or fetch it as anything other than image bytes, and
MUST restrict it to `data:` or `https:` URIs.
* **`code` is display text.** The Platform SHOULD present it with a copy
affordance. It MUST NOT be interpreted as a URL, deep link, or executable
content.
* **`instructions_url` is the only loadable field.** When present, the Platform
MUST constrain it to an `https:` origin allowlist advertised for the handler,
MUST open it as a plain document, and MUST NOT auto-submit forms or forward
buyer data to it.
* **`expires_at` is required and bounds the artifact.** It bounds Platform
polling (see [Polling](#polling)) and MUST NOT exceed the checkout-level
`expires_at`; where both are present, this Action-level value takes precedence
for the Action. After it passes, the Platform stops rendering the artifact and
re-fetches checkout state rather than continuing to show a stale QR/code.

## Resolution Flow

```text
1. Platform → Business : Complete Checkout (buyer chose Pix)
2. Business → Platform : status = complete_in_progress
actions {
"com.mercadopago.payment.render_artifact": [
{ "id": "...", "config": { "type": "qr_code", ... } }
]
}
3. Platform : renders QR image + copy button for `code`
(works on web / native / voice — no frame)
4. Buyer : pays via bank app (out of band)
5. Platform → Business : polls Get Checkout (MUST NOT re-drive Complete)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The action relies on this polling mechanism to know the outcome, but it doesn't define a clear polling contract. According to checkout.md, ideally, the action should define one. Otherwise it will be subjected to different platform policies.

The Platform MAY repeat Get Checkout with bounded backoff set by the Action contract or Platform policy, and MUST stop repeated requests at expires_at.

With no contract in the action definition, it falls through to Platform policy, and conforming implementations can diverge wildly.

For reference, the payment-authentication action defines a 30-second polling interval with backoff, but that is not suitable here.

I think this action should state a normative minimum interval and maximum duration (as a MUST/SHOULD prose).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 27db19a — added a normative ## Polling section: the Platform MUST NOT poll more than once every 2s, SHOULD apply exponential backoff, and MUST stop at the Action-level expires_at. No longer left to Platform policy.

6. Mercado Pago : confirms payment → Business resolves
7. Business → Platform : status = completed, order present, Action gone
```

On expiry or failure, the Business returns the checkout with a `recoverable`
error [Message](overview.md#messages) whose `path` points at the exact Action
occurrence, e.g. `$.actions['com.mercadopago.payment.render_artifact'][0]`. The Platform
surfaces the recovery path (for example, refreshing the artifact) rather than
re-driving Complete.

## Polling

The outcome of a render-artifact Action is observed by the Platform polling Get
Checkout; the Platform never re-drives Complete. Absent a contract here, backoff
falls through to Platform policy and conforming implementations diverge, so this
Action defines it:

* The Platform MUST NOT poll Get Checkout more than once every **2 seconds**.
* The Platform SHOULD apply exponential backoff between attempts.
* The Platform MUST stop polling at the Action-level `expires_at` and re-fetch
checkout state once, rather than continuing to poll a stale artifact.

## Scope: In-Session vs. Out-of-Session Settlement

This extension covers artifacts that resolve **within the checkout session's
lifetime**: the Business holds the checkout in `complete_in_progress`, the buyer
pays out of band, and the Platform observes the outcome by polling Get Checkout
before the session's viability window closes.

The dividing line is **not the payment method** but **whether settlement
completes while the checkout session is still alive**:

* **In-session (this extension).** The artifact's `expires_at` fits within the
session's viability window, so the outcome is observable by polling — for
example, an immediate Pix QR paid within minutes.
* **Out-of-session (out of scope here).** Settlement may land after the checkout
session is gone — for example, **Pix with a long expiry or due date (Pix com
vencimento)**, boleto, or cash vouchers. A Platform cannot hold
`complete_in_progress` open for hours or days, so these MUST NOT be surfaced
as a checkout Action resolved by polling. They belong to an order-level flow
where the same display artifact lives on the `order` and the outcome arrives
via an order lifecycle webhook (addressed separately).

Note that the discriminator is temporal, not the method label: a long-expiry
Pix charge behaves like boleto for this purpose. Accordingly, a Business using
this extension MUST issue an artifact whose `expires_at` is bounded by the
checkout session's viability; if the intended expiry exceeds it, use the
order-level async-payment flow instead.
7 changes: 7 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ nav:
- Fulfillment Extension: specification/fulfillment.md
- Loyalty Extension: specification/loyalty.md
- Payment Terms Extension: specification/payment-terms.md
- Payment Render Artifact Extension: specification/payment-render-artifact.md
- Cart Capability:
- Overview: specification/shopping/cart/index.md
- Transports:
Expand Down Expand Up @@ -419,6 +420,12 @@ plugins:
Payment Terms Extension, enabling checkout payment schedules for
immediate and deferred payments such as deposits, installments,
and lodging balance-at-check-in flows.
- specification/payment-render-artifact.md: >-
Payment Render Artifact Extension, defining an in-session, out-of-band
payment Action (e.g., Pix) that surfaces an inert display artifact
(QR image, copy-and-paste code, hosted instructions) for the
Platform to render without redirect or continue_url, resolved by
polling Get Checkout, under a strict render and trust contract.
Cart Capability:
- specification/shopping/cart/index.md: >-
Pre-purchase Cart Capability, detailing item collection, state
Expand Down
87 changes: 87 additions & 0 deletions source/schemas/shopping/payment_render_artifact.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://ucp.dev/schemas/shopping/payment_render_artifact.json",
"name": "com.mercadopago.shopping.render_artifact",
"title": "Payment Render Artifact Extension",
"description": "Extends Checkout with a render-artifact Action for in-session, out-of-band payment methods (e.g., Pix). The Business surfaces an inert display artifact (QR image, copy-and-paste code, hosted instructions) that the Platform renders so the buyer can complete payment in a separate app; the same Complete Checkout operation resolves afterward via polling Get Checkout, with no redirect or continue_url handoff. Declares the Action type 'com.mercadopago.payment.render_artifact' on top of the common Actions primitive.",
"$defs": {
"config": {
"type": "object",
"title": "Payment Render Artifact Config",
"description": "Public, inert display artifact for one outstanding payment method. Contains display data only: no PAN, OTP, credential, or other secret ever transits this artifact. Render fields are non-executable — 'image' is shown as a static image (never as HTML or script), 'code' is display text, and 'instructions_url' is the only loadable field.",
"required": ["type", "expires_at"],
"properties": {
"type": {
"type": "string",
"description": "The display artifact kind, so the Platform knows how to render it. Reverse-domain or short token defined by this extension.",
"examples": ["qr_code"]
},
"code": {
"type": "string",
"minLength": 1,
"description": "Copy-and-paste payment string (Pix 'copia e cola'). Display text only — the Platform SHOULD offer a copy affordance. MUST NOT be interpreted as a URL or executed."
},
"image": {
"type": "string",
"format": "uri",
"description": "QR (or equivalent) image encoding the payment, as a 'data:' or 'https:' URI. The Platform MUST render it as a static image only and MUST NOT execute, parse, or fetch it as anything other than image bytes."
},
"instructions_url": {
"type": "string",
"format": "uri",
"description": "Optional hosted, human-readable payment instructions. The ONLY loadable field in this artifact; the Platform MUST constrain it to an 'https:' origin allowlist advertised for the handler and MUST NOT auto-submit or pass buyer data to it."
},
"reference": {
"type": "string",
"description": "Opaque payment reference (e.g., Pix end-to-end id) for reconciliation. Not buyer-facing; not a credential."
},
"expires_at": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two points related to expires_at:

  1. expires_at is not marked as a required field. However, the spec strongly relies on its presence, since the rendered artifact is only valid until a specific expiration timestamp.

  2. The parent capability, checkout, also has an expires_at field. For clarity and to avoid ambiguity, we should explicitly define the precedence between the two. Otherwise, an implementer might prioritize the capability-level value over the action-level one, which would be incorrect.

Therefore, I suggest marking the action-level expires_at as required and explicitly stating that polling is bounded by this action-level value. Also, the action-level expires_at should not exceed the checkout-level expires_at.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 27db19aexpires_at is now in the config required set, explicitly bounds polling, and MUST NOT exceed the checkout-level expires_at (Action-level takes precedence for the Action). Precedence is stated in both the schema field description and the render/trust contract.

"type": "string",
"format": "date-time",
"description": "RFC 3339 timestamp after which this artifact is no longer valid. Required; it bounds Platform polling and MUST NOT exceed the checkout-level 'expires_at'. On expiry the Platform stops rendering it and re-fetches checkout state."
}
},
"anyOf": [
{ "required": ["code"] },
{ "required": ["image"] },
{ "required": ["instructions_url"] }
]
},
"instance": {
"title": "Payment Render Artifact Action Instance",
"description": "One outstanding payment render-artifact Action: the common Action instance with a render-artifact 'config'.",
"allOf": [
{ "$ref": "../common/types/actions.json#/$defs/instance" },
{
"type": "object",
"properties": {
"config": { "$ref": "#/$defs/config" }
}
}
]
},
"dev.ucp.shopping.checkout": {
"title": "Checkout with Payment Render Artifact",
"description": "Checkout whose 'actions' map MAY carry outstanding 'com.mercadopago.payment.render_artifact' instances while payment is pending out of band.",
"allOf": [
{ "$ref": "checkout.json" },
{
"type": "object",
"properties": {
"actions": {
"type": "object",
"properties": {
"com.mercadopago.payment.render_artifact": {
"type": "array",
"minItems": 1,
"description": "Outstanding payment render-artifact Actions. Typically a single instance for the buyer's chosen method.",
"items": { "$ref": "#/$defs/instance" }
}
}
}
}
}
]
}
}
}
Loading