Printed Dynamic QRs
Create a permanent, printable QR code for a checkout, and attach the charge of each sale to it.
A Printed Dynamic QR is a permanent QR code that you print for a physical point of sale. You create it one time and put it at a checkout. It does not change. When you charge a payer, the QR Code Creation endpoint attaches the charge to the printed QR. The payer scans the same printed QR, and the bank application shows the amount to pay.
Each sale (the charge that you create for one payer on a printed checkout) expires after 5 minutes. The expire field of QR Code Creation does not apply to a sale.
Charges on a Printed Dynamic QR can use blockchain batched settlement. If the checkout uses it, kamiPay acknowledges each charge immediately, and pays the charges in one USDt transfer for each window.
Recommended flow: use the QR Code Creation endpoint (create_dynamic_pix) with the checkout_id and store_id of a checkout in printed mode. kamiPay attaches the sale to the printed QR of the checkout, so the payer who scans it sees the amount. A checkout holds one pending sale at a time. Before you design the flow at your counter, read One pending sale per checkout.
How charges work
You do not call a different endpoint to create a sale. You configure the checkouts that use a Printed Dynamic QR as their default. After that, the usual create_dynamic_pix request creates a sale on the checkout:
- kamiPay finds that the checkout of
checkout_idandstore_idhas a Printed Dynamic QR as its default. If you do not send them, the checkout is checkout1of store1. - kamiPay attaches the sale to the printed QR of the checkout. The payer who scans the printed QR sees the amount of the sale.
- The response has
qr_type: "printed_dynamic"and the EMV of the printed QR inprinted_dynamic_qr_data.pqr_emv. It also has anemvthat is for the checkout, not for the sale (see rule 2 of One pending sale per checkout). - When the payer pays, the webhooks identify the sale by its
pix_idandkamipay_id.
Always read qr_type. For a printed checkout, it can be "dynamic". Use the flow that qr_type names, not the flow that you expect for the checkout.
Your POS kamiPay Gateway Payer
│ │ │ │
│ POST │ │ │
│ create_dynamic_pix │ │ │
│ (checkout_id, │ │ │
│ store_id, amount) │ │ │
│─────────────────────►│ │ │
│ │ │ │
│ │ Checkout has Printed │ │
│ │ Dynamic QR │ │
│ │ → attach charge to QR │ │
│ │────────────────────────►│ │
│ │◄────────────────────────│ │
│ │ │ │
│◄─────────────────────│ │ │
│ { emv, qr_type, │ │ │
│ printed_dynamic_ │ │ │
│ qr_data: { │ │ │
│ pqr_emv, … }, │ │ │
│ operation_id, … } │ │ │
│ │ │ │
│ Show returned QR │ │ │
│ (or the payer scans │ │ │
│ the printed one) │ │ │
│ │ │◄──── Scans QR ────│
│ │ │ Payment settled │
│ │◄──────── Webhook ───────│ │
│◄──── Notification ───│ │ │
│ │ │ │One pending sale per checkout
A printed checkout holds one pending sale at a time. Design the flow at your counter for these rules:
- A new sale replaces the unpaid sale before it. The replaced sale is a displaced sale. This is intentional: with a new sale, a cashier corrects a wrong amount, and does not wait until the first sale expires. kamiPay does not tell you about the displaced sale.
- Each QR of the checkout charges the newest sale. This applies to the printed QR, and also to the
emvthat the API returned for an older sale. Thatemvis for the location of the checkout, not for the sale. With some payment gateways, it is the EMV of the printed QR. With other payment gateways, it is a different EMV, and its text still shows the older amount. - kamiPay credits the payment to the newest sale. The QR that the payer scans does not change this. The webhooks have the
pix_idandkamipay_idof the newest sale. - kamiPay never sends a webhook for a displaced sale. Tx Status continues to return
"TX not found"for it. Thus, a displaced sale never closes by itself. - But a displaced sale is not final. If the payer paid just before you created the new sale, kamiPay can report that payment for the displaced sale later.
- If no sale is pending, the payer cannot read the printed QR. The bank application of the payer says that it cannot scan the code. Thus, the cashier must create the sale before the payer scans.
What to do
- Treat a new sale as the cancellation of the unpaid sale before it. Mark the earlier sale as displaced immediately. Show only the QR that the API returned for the newest sale. But still accept a late
processingfor the displaced sale. - Create a new sale while a sale is pending only to correct the amount. For the next payer, wait for the
processingof the current sale, or until the sale expires. - Match each webhook by
pix_idorkamipay_id, never by the QR on the screen. Then make sure that itsamount_brlis the amount of the sale that you expect.
Errors when creating a sale
On a printed checkout, create_dynamic_pix can also return these errors. The API does not create a sale for them.
| Status | detail | When |
|---|---|---|
| 422 | "This checkout does not have a printed dynamic QR assigned.Please create one or contact support if you need assistance." | The default_qr of the checkout is printed_dynamic_qr, but the checkout has no Printed Dynamic QR. Create one for the checkout. |
| 503 | "printed_qr_disabled" | kamiPay temporarily disabled Printed Dynamic QRs. Send the request again later. Until then, Create Printed Dynamic QR also returns this error. |
The 422 detail is the exact text that the API returns, with no space after assigned.. In your code, use the status code, not the text.
Checkout configuration
You configure Printed Dynamic QRs yourself, through the API. A checkout is in printed mode when it has a Printed Dynamic QR and its default_qr configuration is printed_dynamic_qr. There are two procedures:
- In one request: create the Printed Dynamic QR with
auto_create_checkout: true. If the checkout does not exist, kamiPay creates it. Then kamiPay creates the printed QR, and only after that setsdefault_qr. This also works on an existing checkout (see Auto-creating the checkout in one call). - On an existing checkout, step by step: create the Printed Dynamic QR without
auto_create_checkout. The checkout continues to charge usual dynamic QRs, so you can print the QR before you use it. To change the checkout to printed mode, setdefault_qrtoprinted_dynamic_qrwith Checkout Configs. Create the QR first: a checkout in printed mode with no printed QR rejects each sale with 422.
After this configuration, you do not change your integration. The usual create_dynamic_pix request uses the Printed Dynamic QR. List Checkouts shows the default_qr of the checkout.
Create Printed Dynamic QR
POST /v2/printed_dynamic_qrs
Creates a permanent QR code for a checkout of a store. If the checkout already has a Printed Dynamic QR, the API returns that QR with HTTP 200, not 201. The checks of the request come first, also when the checkout has a QR. For example, a wallet that is not on Polygon gets its 400.
Body Parameters
| Name | Type | Description |
|---|---|---|
| checkout_id | integer | Required. The checkout where you put the QR. With auto_create_checkout: true, kamiPay gives this checkout_id to the new checkout. kamiPay does not select a different number. |
| store_id | integer | Optional. The store of the checkout. Default: 1. |
| auto_create_checkout | boolean | Optional. Default: false. With true, if the checkout does not exist, kamiPay creates it with the checkout_id that you send. Then kamiPay creates the printed QR, and only after that sets default_qr=printed_dynamic_qr on the checkout. Thus, one request creates a checkout and its printed QR. This also works on an existing checkout: see Auto-creating the checkout in one call and the two warnings after this table. |
| checkout_desc | string | Optional. The description of the checkout that auto_create_checkout creates, from 1 to 140 characters. Out of this range, the API rejects the request with 422. If the checkout already exists, kamiPay ignores it. |
| wallet_id | integer | Optional, only with auto_create_checkout: true. The wallet that the new checkout settles to. It must be a wallet of your account. Send wallet_id or address, not both. |
| address | string | Optional, only with auto_create_checkout: true. The address of one of your wallets, as an alternative to wallet_id. If the address is blank or has more than 100 characters, the API rejects the request with 422. |
The default wallet and store of auto_create_checkout. If you send auto_create_checkout: true without wallet_id or address, the new checkout gets the wallet of checkout 1 of the same store_id. It does not get a general wallet of your account. If you do not send store_id, its value is 1, so the wallet comes from checkout (store_id=1, checkout_id=1). If the store has no checkout 1, the API rejects the request with 400.
To configure the store, the checkout or the wallet yourself, use the Stores and Checkouts endpoints. Then send this request. With auto_create_checkout: true, kamiPay changes the existing checkout to printed mode. Without it, the checkout stays dynamic until you set default_qr (see Checkout configuration).
A retry cannot change the wallet. If the checkout already exists, it keeps its wallet. If the wallet_id or address that you send is a different wallet, the API rejects the request with 409. To move a checkout to a different wallet, use Update Checkout.
Example Request
const url = `${baseURL}/v2/printed_dynamic_qrs`;
const body = {
checkout_id: 1,
store_id: 1,
};
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});import requests
url = f"{base_url}/v2/printed_dynamic_qrs"
body = {
"checkout_id": 1,
"store_id": 1,
}
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
response = requests.post(url, headers=headers, json=body)package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
func main() {
url := baseURL + "/v2/printed_dynamic_qrs"
body := map[string]interface{}{
"checkout_id": 1,
"store_id": 1,
}
requestBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(requestBody))
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken))
req.Header.Add("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Println("Error:", err)
return
}
defer resp.Body.Close()
}Response
{
"printed_dynamic_qr_id": "pqr_01km0mbfxdf4g87eb8yytas95k",
"qr_emvcode": "00020101021226930014br.gov.bcb.pix...",
"qr_location_id": "3f6c1a9e-7b2d-4e8f-a5c4-9d0b2e7f1a63",
"address": "0x46d52020**********1fbdcc297d",
"checkout_id": 1,
"merchant_id": 42,
"store_id": 1,
"created_at": 1730455200
}{
"printed_dynamic_qr_id": "pqr_01km0mbfxdf4g87eb8yytas95k",
"qr_emvcode": "00020101021226930014br.gov.bcb.pix...",
"qr_location_id": "3f6c1a9e-7b2d-4e8f-a5c4-9d0b2e7f1a63",
"address": "0x46d52020**********1fbdcc297d",
"checkout_id": 1,
"merchant_id": 42,
"store_id": 1,
"created_at": 1728981000
}{
"detail": "send either wallet_id or address, not both"
}Other detail values:
detail | When |
|---|---|
"checkout_id is required" | You did not send checkout_id. |
"store_id does not exist" | With auto_create_checkout: true, the store_id is not one of your stores. |
"User does not have access to this store" | Your user has no access to the store. |
"wallet_id/address require auto_create_checkout" | You sent wallet_id or address without auto_create_checkout: true. |
"Wallet not found" | The wallet does not exist, or it is a wallet of a different account. |
"Wallet network deprecated" | The wallet is not on Polygon (see Wallets). The API examines three wallets. These are the wallet that you send, the wallet of checkout 1 for a new checkout, and the wallet of an existing checkout. It examines the last one also when the checkout has a QR. |
"Default checkout (store_id=1, checkout_id=1) not found; cannot derive wallet_id for auto-create" | You sent auto_create_checkout: true for a new checkout, without wallet_id or address, and the store has no checkout 1 to give its wallet. |
With an invalid or expired token:
{
"detail": "Could not validate credentials"
}You did not send auto_create_checkout: true, and the checkout does not exist:
{
"detail": "Checkout not found for this merchant"
}The checkout exists, and its wallet is not the wallet_id or address that you sent:
{
"detail": "checkout already exists with a different wallet; update it via PATCH /v2/stores/1/checkouts/500"
}{
"detail": "Printed dynamic QRs aren't available for this merchant"
}The payment gateway of your account does not support Printed Dynamic QRs. kamiPay does not create the printed QR. With auto_create_checkout: true, a new checkout stays dynamic, as after a 502.
A checkout_desc or an address out of its range also gets 422, with a list in detail.
{
"detail": "Internal Server Error during printed dynamic QR creation"
}{
"detail": "Gateway error during printed dynamic QR creation"
}The payment gateway failed when it created the printed QR. The detail can also be "Gateway did not return expected fields for printed dynamic QR creation". In both cases, kamiPay does not create a printed QR, and does not change the checkout to printed mode. To complete the operation, send the same request again. If the request had auto_create_checkout: true, it also changes the checkout to printed mode (see Auto-creating the checkout in one call).
{
"detail": "printed_qr_disabled"
}kamiPay temporarily disabled Printed Dynamic QRs. Send the request again later.
Response Fields
| Field | Type | Description |
|---|---|---|
| printed_dynamic_qr_id | string | The unique identifier of this Printed Dynamic QR (pqr_…). |
| qr_emvcode | string | The EMV text to print as the permanent QR code. |
| qr_location_id | string | The identifier of the location of this QR at the payment gateway. |
| address | string | The address of the wallet that the checkout of the QR settles to now. If the wallet of the checkout changes, this value also changes. The examples on this page hide part of it. |
| checkout_id | integer | The checkout of this QR. |
| merchant_id | integer | The merchant account that owns this QR. |
| store_id | integer | The store of this QR. |
| created_at | integer | The Unix timestamp (epoch seconds) of the creation of the Printed Dynamic QR. |
HTTP 200 means that the checkout already had a Printed Dynamic QR. The API returns the existing QR, and does not create a new QR. If you sent auto_create_checkout: true and the checkout is not in printed mode, kamiPay also changes it to printed mode.
Auto-creating the checkout in one call
Send auto_create_checkout: true to do three operations in one request: create the checkout, create the printed QR, and change the checkout to printed mode (default_qr=printed_dynamic_qr). This also works on an existing checkout. kamiPay uses that checkout, and changes it to printed mode when its QR exists, also if you set it to dynamic QRs again before. If the wallet_id or address is not the wallet of the existing checkout, the API still rejects the request with 409.
const response = await fetch(`${baseURL}/v2/printed_dynamic_qrs`, {
method: "POST",
headers: {
Authorization: `Bearer ${access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
checkout_id: 500, // the id you want the new checkout to have
auto_create_checkout: true, // store_id omitted → defaults to 1
}),
});If you do not send wallet_id or address, the new checkout gets the wallet of checkout 1 of the same store_id. After this request, each create_dynamic_pix request for that (store_id, checkout_id) uses the Printed Dynamic QR flow.
If the payment gateway fails
- kamiPay creates the checkout before it calls the payment gateway. kamiPay changes the checkout to printed mode only when its printed QR exists.
- The API rejects the request with
502. The checkout stays dynamic, and continues to charge usual dynamic QRs. - To complete the operation, send the same request again, with
auto_create_checkout: true. The request creates the QR and changes the checkout to printed mode.
List Printed Dynamic QRs
GET /v2/printed_dynamic_qrs
Returns the Printed Dynamic QRs of your account, grouped as stores → checkouts → printed_dynamic_qrs. Use it to find the checkouts that already have a printed QR.
Query Parameters
| Name | Type | Description |
|---|---|---|
| store_id | integer | Optional. Only the QRs of this store. |
| checkout_id | integer | Optional. Only the QRs of this checkout. If you send it without store_id, the API uses store_id=1. |
| page | integer | Optional. The page number, from 1. Default: 1. |
| page_size | integer | Optional. The number of QRs on each page, from 1 to 100. Default: 100. |
If page or page_size is out of range, the API rejects the request with 422.
Example Request
const url = `${baseURL}/v2/printed_dynamic_qrs?page=1&page_size=100`;
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${access_token}`,
},
});import requests
url = f"{base_url}/v2/printed_dynamic_qrs"
headers = {"Authorization": f"Bearer {access_token}"}
params = {"page": 1, "page_size": 100}
response = requests.get(url, headers=headers, params=params)package main
import (
"fmt"
"net/http"
)
func main() {
url := baseURL + "/v2/printed_dynamic_qrs?page=1&page_size=100"
req, _ := http.NewRequest("GET", url, nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken))
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Println("Error:", err)
return
}
defer resp.Body.Close()
}Response
{
"total": 2,
"page": 1,
"page_size": 100,
"stores": [
{
"store_id": 1,
"store_name": "Main Store",
"company_name": "Acme Ltda",
"checkouts": [
{
"checkout_id": 4,
"checkout_desc": "Register 4",
"printed_dynamic_qrs": [
{
"printed_dynamic_qr_id": "pqr_01km0mbfxdf4g87eb8yytas95k",
"qr_emvcode": "00020101021226930014br.gov.bcb.pix...",
"address": "0x46d52020**********1fbdcc297d"
}
]
},
{
"checkout_id": 500,
"checkout_desc": "Auto created via printed dynamic qr for 500",
"printed_dynamic_qrs": [
{
"printed_dynamic_qr_id": "pqr_01km2v7c4hf9t3x8q6wz5n1b2d",
"qr_emvcode": "00020101021226930014br.gov.bcb.pix...",
"address": "0x46d52020**********1fbdcc297d"
}
]
}
]
}
]
}With an invalid or expired token:
{
"detail": "Could not validate credentials"
}A query parameter is out of range. For example, page_size=101:
{
"detail": [
{
"type": "less_than_equal",
"loc": ["query", "page_size"],
"msg": "Input should be less than or equal to 100",
"input": "101",
"ctx": {
"le": 100
}
}
]
}{
"detail": "Internal Server Error during printed dynamic QR listing"
}Response Fields
| Field | Type | Description |
|---|---|---|
| total | integer | The number of Printed Dynamic QRs that match the filters, on all pages. |
| page | integer | The page number, as in the request. |
| page_size | integer | The maximum number of QRs on each page, as in the request. |
| stores | array | The stores that have at least one matching Printed Dynamic QR. Each store has its checkouts. |
| stores[].store_id | integer | The store identifier. |
| stores[].store_name | string | null | The name of the store. |
| stores[].company_name | string | null | The name of the company that owns the store. |
| stores[].checkouts | array | The checkouts of the store that have at least one matching Printed Dynamic QR. |
| stores[].checkouts[].checkout_id | integer | The checkout identifier. |
| stores[].checkouts[].checkout_desc | string | null | The description of the checkout. |
| stores[].checkouts[].printed_dynamic_qrs | array | The Printed Dynamic QRs of the checkout. |
| stores[].checkouts[].printed_dynamic_qrs[].printed_dynamic_qr_id | string | The unique identifier of the QR. |
| stores[].checkouts[].printed_dynamic_qrs[].qr_emvcode | string | The EMV text of the printed QR. |
| stores[].checkouts[].printed_dynamic_qrs[].address | string | The address of the wallet that the checkout of the QR settles to now. If the wallet of the checkout changes, this value also changes. |
To set or read the default_qr configuration of a checkout, see Checkout Configs in Account Administration.