About payment-ppp-pos
payment-ppp-pos is a skill for creating, extending, or debugging VTEX Sales App payment connectors for physical POS terminals. It focuses on the POS-specific PPP state flow and VTEX IO / PPF constraints, rather than generic ecommerce payment connectors.
Key features
- 01Implements POS payment-flow endpoints
- 02Guides terminal identification and polling
- 03Enforces POS payment method names
- 04Separates POS from ecommerce card handling
- 05Provides connector code and test checklist
Use cases
- 01Creating a VTEX IO POS connector
- 02Adding POS support to existing PPP middleware
- 03Debugging terminal identification and payment polling
- 04Preparing POS connector validation tests
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ vtex/ skills --skill payment-ppp-posAfter installing, type/payment-ppp-posin Claude Code to confirm it loaded.
Bundled files
References and scripts installed along with SKILL.md.
references/examples.mdgeneration.mdppf-scaffold.mdDetails
From the project READMEPPP Applied to POS (VTEX Sales App)
When this skill applies
Use this skill when the task is to create, extend, or debug a payment connector that charges a physical card on a POS terminal from VTEX Sales App.
- The user mentions PPP + POS, POS connector, in-store payments, Sales App card payments, payment terminal, or
Venda Direta Credito/Venda Direta Debito - The source of truth is Payment Provider Protocol for Point of Sale (POS)
- The goal is to remove the "implement from scratch" barrier: clone the official PPF example from Payment Provider Framework Getting started, or overlay POS on existing PPP — not generate a generic ecommerce connector
Load companions when the step needs them. Do not dump all three into the first reply.
| Companion | Load when |
|---|---|
| generation.md | The user asked to create a POS connector. Ask the four questions, then implement in the listed order. |
| ppf-scaffold.md | Hosting is VTEX IO / PPF. Copy overlay files onto the official PPF example app. |
| examples.md | You need expected JSON, the state machine table, or IO retry vs standalone notification. |
The hard constraints below override ecommerce PPP and PCI habits. If a generated file violates one, STOP and fix it before writing more files.
Do not use this skill as the default choice for:
- Ecommerce PPP endpoints, manifests, and homologation shapes with no POS methods — use
payment-provider-protocol - IO connector wiring (
PaymentProvider,configuration.json,this.retry) — usepayment-provider-frameworktogether with this skill - Pix, Boleto, or browser-redirect async methods — use
payment-async-flow - Ecommerce card data and Secure Proxy — use
payment-pci-security - Sales App cart, PDP, or menu UI extensions — use
sales-app-extensibility
Decision rules
- Prefer VTEX IO / PPF unless the partner already hosts PPP middleware. Clone the official example app from the Payment Provider Framework guide and overlay POS; do not start from an empty Express app.
- POS payments are still PPP. Implement the six payment-flow endpoints, then overlay the POS rules in this skill.
authorize()is a state machine. It never waits on the POS. It only returns the current phase. The connector answers Create Payment; it does not push the Gateway between phases. After the terminal app closes, VTEX Sales App triggers a Gateway callback that issues Create Payment (POS guide step 8). Wait for confirmation then polls the Gateway, which issues more Create Payment (step 9). Do not POSTcallbackUrluntil the processor reports a final status — retry-on-pending is unnecessary and adds about 1 second per authorize.
| Stored phase | Who drives the next Create Payment | Create Payment returns |
|---|---|---|
| (none yet) | Sales App checkout → Gateway (step 3) | Persist identify-terminal; undefined + vtex.terminal-connector-app |
identify-terminal (no serial) | Gateway / Sales App | Same terminal app until submitUrl stores a serial |
identify-terminal (serial stored) | Sales App after the terminal app closes (step 8) | Start the processor charge once; undefined + vtex.challenge-wait-for-confirmation |
await-pos | Wait for confirmation polling (step 9) | Same wait app; no second charge; no retry POST |
approved / denied | Wait app / Sales App after IO retry | Final status + cardBrand + firstDigits + lastDigits |
Venda Direta CreditoandVenda Direta Debitoare asynchronous card-present methods. Returnstatus: "undefined"and resolve later viacallbackUrl. Do not treat them as sync Visa/Mastercard authorizations.- Branch on
paymentMethod. On PPF, useisDirectSaleAuthorizationfrom@vtex/payment-provider(canonicalDirectSalenames, and the narrowed type has nocard/secureProxyUrl). On standalone HTTP, compare to those same ASCII strings. A connector may support ecommerce and POS. - The Gateway does not receive the shopper's card for POS. All
cardfields arrive asnull. Do not call Secure Proxy on this path. - Use VTEX's ready-made Payment Apps unless the partner has a documented reason to build a custom one:
- Identify the terminal:
vtex.terminal-connector-app - Wait for the POS result:
vtex.challenge-wait-for-confirmation
- Identify the terminal:
- Do not use
vtex.challenge-terminal-connector-app. That name appears as a flow-diagram example in the POS guide; the implementation contract and the published app arevtex.terminal-connector-app. paymentAppData.payloadis a serialized JSON string, not a nested object. On PPF, build pending responses withAuthorizations.pendingso that is a compile error. Do not close those objects withas AuthorizationResponse— that cast hides the mistake.cardBrand,firstDigits, andlastDigitsare required by the POS guide but are not on the SDK response types (checked on@vtex/payment-provider1.4.0 and 1.8.0); the final approved/denied response is the one place a cast is justified.- Communication between the payment processor and the physical POS is outside VTEX. The connector only identifies the terminal, starts the processor transaction, and reports
approvedordeniedto the Gateway. - Without VTEX IO,
callbackUrlis a notification endpoint: POST the final status (include POS card fields). With VTEX IO,callbackUrlis a retry endpoint: persist state, then POST it with an empty body. Inside aPaymentProvidermethod usethis.retry(request). From aservice.jsonextra route usenew Payments(ctx.vtex).retry(callbackUrl)— same client asthis.retry(gateway-retry metric, IO tracing, configured timeout). Do not POSTstatus/cardBrandon IO. Return the final status plus POS card fields on the next Create Payment /authorize(). - POS serial collection and the processor webhook are extra public routes, not PPP inbound-request. The official example's
this.callback(req, resp)is Test Suite only. delayToCancelmust be greater than or equal tosecondsWaiting.- Every connector endpoint must respond in under 20 seconds in production (under 5 seconds during the Payment Provider Test Suite).
- This skill can generate connector code and a test checklist. It cannot replace a real POS, a Sales App device, a configured test account, or homologation.
Hard constraints
Code samples use example identifiers (PartnerPos, partnername, connector-partnerpos, provider.example.com) and example numbers (SECONDS_WAITING = 600, firstDigits / lastDigits such as 411111 / 1111). Replace them with the partner's connector name, public hosts, timeouts, and processor card fragments. 600 is not a VTEX-required default; the rule is delayToCancel >= secondsWaiting. Example BIN/last-four values are truncated fragments, not a PAN.
Constraint: Manifest must declare the POS payment methods by exact API name
GET /manifest (and PPF paymentProvider/configuration.json paymentMethods) MUST include Venda Direta Credito and/or Venda Direta Debito with the exact ASCII names from the POS guide. Admin labels with accents (Venda Direta Crédito, Venda Direta Débito) are display names only.
Why this matters
The Gateway and Sales App payment conditions bind to these method names. If the manifest only lists Visa / Mastercard, merchants cannot configure POS. If the connector invents a name such as "POS Credit", Admin and Sales App will not attach the in-store flow.
Detection
If the task is POS and paymentMethods lacks Venda Direta Credito or Venda Direta Debito, STOP and add them before generating other code.
Correct
{
"paymentMethods": [
{ "name": "Venda Direta Debito", "allowsSplit": "onCapture" },
{ "name": "Venda Direta Credito", "allowsSplit": "onCapture" }
]
}
Wrong
{
"paymentMethods": [
{ "name": "Visa", "allowsSplit": "onCapture" },
{ "name": "Mastercard", "allowsSplit": "onCapture" },
{ "name": "POS Credit", "allowsSplit": "onCapture" }
]
}
Constraint: Detect POS from paymentMethod and never read card PAN from the Gateway
For Venda Direta Credito and Venda Direta Debito, Create Payment sends card.holder, card.number, card.csc, card.expiration, card.document, and card.token as null. The connector MUST NOT require those fields, MUST NOT call Secure Proxy, and MUST NOT decline the payment because card data is missing.
On PPF, branch with isDirectSaleAuthorization from @vtex/payment-provider. That guard uses the SDK DirectSale names (exact ASCII), and DirectSaleAuthorization has no card and no secureProxyUrl — so authorization.card is a compile error after the narrow. Do not hand-roll POS_METHODS or (authorization as any).paymentMethod. On standalone HTTP, compare paymentMethod to those same two strings. callbackUrl and value are already on Authorization; do not cast them.
Why this matters The shopper pays on the physical terminal. Treating POS as an ecommerce card authorization fails the charge, or worse, invents Secure Proxy calls with empty tokens. A hand-rolled accented string fails at the Gateway instead of at compile time.
Detection
If POS authorize logic reads card.number / secureProxyUrl or returns denied when card is null, STOP and split POS from ecommerce. If PPF code uses as any to read paymentMethod, callbackUrl, or value, STOP and use the typed fields / guard.
Correct
import {
isDirectSaleAuthorization,
AuthorizationRequest,
} from "@vtex/payment-provider";
async function authorize(request: AuthorizationRequest) {
if (isDirectSaleAuthorization(request)) {
return authorizePos(request);
}
return authorizeEcommerce(request);
}
Wrong
async function authorize(request: CreatePaymentRequest) {
if (!request.card?.number) {
return { status: "denied", message: "Missing card number" };
}
return secureProxy.charge(request.card, request.secureProxyUrl);
}
Constraint: First Create Payment response must open the terminal Payment App
When the connector has not yet received the POS serial number for this paymentId, Create Payment MUST return status: "undefined" and paymentAppData for vtex.terminal-connector-app. payload MUST be a JSON string with submitUrl, the HTTPS endpoint that receives {"serialNumber": "12345"}.
Replace the host in examples with the partner's real public URL. Persist callbackUrl and paymentId before responding.
On PPF, return Authorizations.pending(authorization, { delayToCancel, paymentAppData }). AppData.payload is Maybe<string>, so an object payload is a compile error. Do not wrap that object in as AuthorizationResponse.
Why this matters
Without this response, Sales App never opens the camera/barcode challenge, so the processor never learns which terminal to use. A nested payload object is not injected into the Payment App. A whole-response cast turns that compile error off.
Detection
If the first POS response is approved/denied, omits paymentAppData, uses vtex.challenge-terminal-connector-app, sets payload as an object, or uses as AuthorizationResponse on a pending body, STOP and fix.
Correct
import { Authorizations } from "@vtex/payment-provider";
const SECONDS_WAITING = 600;
async function authorizePos(request: CreatePaymentRequest) {
const existing = await store.get(request.paymentId);
if (!existing) {
await store.save(request.paymentId, {
phase: "identify-terminal",
status: "undefined",
callbackUrl: request.callbackUrl,
paymentMethod: request.paymentMethod,
value: request.value,
});
return Authorizations.pending(request, {
acquirer: "PartnerPos",
code: "POS_IDENTIFY_TERMINAL",
message: "Identify the POS terminal",
delayToAutoSettle: 21600,
delayToAutoSettleAfterAntifraud: 1800,
delayToCancel: SECONDS_WAITING,
paymentAppData: {
appName: "vtex.terminal-connector-app",
payload: JSON.stringify({
submitUrl: `https://provider.example.com/pos/serial/${request.paymentId}`,
}),
},
});
}
return continuePos(request, existing);
}
Wrong
return {
paymentId: request.paymentId,
status: "approved",
paymentAppData: {
appName: "vtex.challenge-terminal-connector-app",
payload: {
submitUrl: "https://provider.example.com/pos/serial",
},
},
};
Constraint: After the serial number, wait with vtex.challenge-wait-for-confirmation
When submitUrl receives {"serialNumber": "..."}, the connector MUST associate that serial with paymentId (POS guide step 7). Do not start the POS charge in that handler.
The next Create Payment — driven by Sales App after the terminal app closes (step 8) — MUST start the processor charge once, then return status: "undefined" plus vtex.challenge-wait-for-confirmation with payload {"secondsWaiting": <int>} (steps 8.b–8.d). Later polls (step 9) MUST NOT start another charge and MUST NOT POST callbackUrl.
Do not start the POS charge before the serial number exists, and do not start it from the first authorize() (no serial yet).
secondsWaiting is how long the Wait for confirmation app polls. delayToCancel (seconds, Create Payment response) is how long the Gateway keeps the payment undefined before calling Cancel. Set delayToCancel >= secondsWaiting.
Why this matters
Starting the terminal before it is identified charges the wrong device or none. Starting the charge in submitUrl diverges from the published sequence (8.b starts the payment, 8.c confirms it started, 8.d returns the wait app). If delayToCancel is shorter than secondsWaiting, the Gateway cancels while Sales App is still waiting. If Create Payment starts a new processor charge on every poll, the shopper is charged twice.
Detection
If Wait for confirmation is returned before a serial is stored, if startPosCharge runs in the serial handler or on every poll, if secondsWaiting is missing, or if delayToCancel < secondsWaiting, STOP.
Correct
async function onSerialNumber(paymentId: string, serialNumber: string) {
const payment = await store.get(paymentId);
if (!payment || payment.phase !== "identify-terminal") {
return;
}
await store.save(paymentId, {
...payment,
serialNumber,
});
}
function waitingResponse(request: CreatePaymentRequest) {
return Authorizations.pending(request, {
acquirer: "PartnerPos",
code: "POS_WAITING",
message: "Waiting for POS confirmation",
delayToAutoSettle: 21600,
delayToAutoSettleAfterAntifraud: 1800,
delayToCancel: SECONDS_WAITING,
paymentAppData: {
appName: "vtex.challenge-wait-for-confirmation",
payload: JSON.stringify({ secondsWaiting: SECONDS_WAITING }),
},
});
}
Wrong
async function authorizePos(request: CreatePaymentRequest) {
await processor.startPosCharge({ paymentId: request.paymentId });
return {
paymentId: request.paymentId,
status: "undefined",
delayToCancel: 30,
paymentAppData: {
appName: "vtex.challenge-wait-for-confirmation",
payload: JSON.stringify({ secondsWaiting: 600 }),
},
};
}
Constraint: Approve or deny only through the mandatory callback, including POS card fields
The processor, not VTEX, reads the physical card. When it reports a final result, the connector MUST notify the Gateway. POS cannot be approved or denied without this callback. Include:
status:"approved"or"denied"cardBrandfirstDigits(first six digits / BIN)lastDigits(last four digits)
Those three card fields are required for merchant reconciliation. They come from the processor after the terminal reads the card — not from the original Create Payment card object.
Do not log or store the full PAN. firstDigits + lastDigits are the allowed fragments.
Why this matters
Without notifying the Gateway, Sales App stays on Wait for confirmation until timeout, then cancels. Missing BIN/last-four breaks financial reports even when the charge succeeded on the terminal. On IO, posting a standalone notification body to callbackUrl does not complete the flow — the Gateway expects a bodiless retry POST, then a new authorize().
Detection
If POS code returns approved on the first Create Payment, STOP. If PPF code fetches callbackUrl with status / cardBrand in the body, STOP and use Payments.retry / this.retry. If a Service route calls this.retry (there is no this), STOP and use new Payments(ctx.vtex).retry(stored.callbackUrl). If pending authorize() POSTs callbackUrl to "nudge" the Gateway, STOP. If standalone code omits the three card fields on the notification POST, STOP.
Correct (PPF extra route — typical processor webhook)
import { Payments } from "@vtex/payment-provider";
await vbase.saveJSON("pos", paymentId, {
status: "approved",
cardBrand: event.cardBrand,
firstDigits: event.firstDigits,
lastDigits: event.lastDigits,
authorizationId: event.authorizationId,
nsu: event.nsu,
tid: event.tid,
});
await new Payments(ctx.vtex).retry(callbackUrl);
Correct (inside a PaymentProvider method only)
return this.retry(authorization);
The next authorize() MUST return status, cardBrand, firstDigits, and lastDigits from the persisted record. See examples.md for the standalone notification payload.
Wrong
async function authorizePos(request: CreatePaymentRequest) {
return {
paymentId: request.paymentId,
status: "approved",
message: "Assume the terminal will succeed",
};
}
// Also wrong on VTEX IO: notification payload to callbackUrl
await fetch(authorization.callbackUrl, {
method: "POST",
body: JSON.stringify({
paymentId: request.paymentId,
status: "approved",
cardBrand: "Visa",
firstDigits: "411111",
lastDigits: "1111",
}),
});
Constraint: Keep Create Payment idempotent and answers under 20 seconds
Create Payment for a given paymentId MUST return the current stored phase and status. It MUST NOT create a second processor transaction. Polling from Wait for confirmation will call Create Payment many times while status is still undefined. Those calls can overlap (different requestId, same paymentId) — a sequential get-then-save can create two records or drop a phase update.
On PPF, persist with getRawJSON (capture etag) then saveJSON(..., ifMatch). On 412, re-read and continue from the stored document. Pair with payment-idempotency. Do not put unique per-request fields (random tid, requestId) on the first identify-terminal write.
VBase composes the metadata bucket as {vendor}.{appName}.{bucket} and rejects names longer than 50 characters (400 Metadata bucket name too large). Use a short bucket such as pos. The skill placeholders (partnername.connector-partnerpos) fit pos-payments; a longer real vendor.appName does not.
Do not wait for the shopper to finish on the POS inside the HTTP handler. Compare-and-set identify-terminal → await-pos, start the processor charge only as the winner of that write, and return. Password retries and insufficient-funds handling stay between the processor and the POS.
Why this matters
The Gateway treats a >20s response as failure. Duplicate POS charges happen when each poll hits the acquirer again. Overlapping first writes for one paymentId return inconsistent tids and break PPP idempotency. A long VBase bucket fails on the first Create Payment with an opaque 400.
Detection
If the handler awaits the terminal result, calls processor.startPosCharge when phase is already await-pos or final, uses getJSON + saveJSON with no etag, or uses a VBase bucket that makes {vendor}.{appName}.{bucket} longer than 50 characters, STOP.
Correct
const POS_BUCKET = "pos";
async function savePos(vbase: VBase, paymentId: string, record: PosRecord, etag?: string) {
try {
await vbase.saveJSON(POS_BUCKET, paymentId, record, undefined, etag);
return true;
} catch (err) {
const status = (err as any).response && (err as any).response.status;
if (status === 412) {
return false;
}
throw err;
}
}
async function continuePos(
vbase: VBase,
request: CreatePaymentRequest,
existing: PosRecord,
etag?: string
) {
if (existing.status === "denied") {
return {
...Authorizations.deny(request, {
acquirer: "PartnerPos",
code: existing.code,
message: existing.message,
tid: existing.tid,
delayToCancel: SECONDS_WAITING,
}),
cardBrand: existing.cardBrand,
firstDigits: existing.firstDigits,
lastDigits: existing.lastDigits,
} as AuthorizationResponse;
}
if (existing.status === "approved") {
return {
...Authorizations.approve(request, {
authorizationId: existing.authorizationId as string,
nsu: existing.nsu,
tid: existing.tid as string,
acquirer: "PartnerPos",
code: existing.code,
message: existing.message,
}),
delayToCancel: SECONDS_WAITING,
cardBrand: existing.cardBrand,
firstDigits: existing.firstDigits,
lastDigits: existing.lastDigits,
} as AuthorizationResponse;
}
if (existing.phase === "await-pos") {
return waitingResponse(request);
}
if (existing.serialNumber && existing.phase === "identify-terminal") {
const won = await savePos(
vbase,
request.paymentId,
{ ...existing, phase: "await-pos" },
etag
);
if (won) {
await processor.startPosCharge({
paymentId: request.paymentId,
serialNumber: existing.serialNumber,
amount: existing.value,
});
}
return waitingResponse(request);
}
return Authorizations.pending(request, {
acquirer: "PartnerPos",
code: "POS_IDENTIFY_TERMINAL",
message: "Identify the POS terminal",
delayToCancel: SECONDS_WAITING,
paymentAppData: {
appName: "vtex.terminal-connector-app",
payload: JSON.stringify({
submitUrl: `https://provider.example.com/pos/serial/${request.paymentId}`,
}),
},
});
}
Wrong
async function authorizePos(request: CreatePaymentRequest) {
const result = await processor.startPosCharge(request);
await processor.waitUntilCardFinished(result, { timeoutMs: 120000 });
return { paymentId: request.paymentId, status: result.status };
}
Constraint: Collect the POS serial on a public custom route, not inbound-request
vtex.terminal-connector-app POSTs {"serialNumber":"..."} to submitUrl. That URL MUST be a public HTTPS endpoint the Payment App can call. On PPF, declare it in service.json routes and pass it into PaymentProviderService. Do not use POST /payments/{paymentId}/inbound-request/{action} for this step.
Why this matters
Inbound-request is Gateway-initiated. The terminal app posts from the Sales App device. If submitUrl is an inbound path, the serial never arrives and the POS charge never starts.
Detection
If submitUrl contains /inbound, STOP. If PPF has no extra service.json route for serial collection, STOP.
Correct
{
"routes": {
"posSerial": {
"path": "/_v/partnerpos/pos/serial/:paymentId",
"public": true
}
}
}
Wrong
{
"submitUrl": "https://account.myvtex.com/_v/api/my-connector/payments/PAY1/inbound/serial"
}
Preferred pattern
Follow generation.md in order. For PPF, overlay POS on the official example app from the Payment Provider Framework guide using ppf-scaffold.md. Compare IO vs standalone payloads in examples.md.
Sales App checkout
→ Gateway POST /payments (Venda Direta Credito|Debito, card = null)
→ authorize(): undefined + vtex.terminal-connector-app (submitUrl)
→ POST submitUrl {"serialNumber":"..."} (public custom route; store serial only)
→ Sales App closes the terminal app and calls the Gateway (connector does not retry)
→ authorize(): start processor charge once, then undefined + vtex.challenge-wait-for-confirmation
→ Wait for confirmation polls authorize() (still undefined; no second charge; no retry POST)
→ Processor webhook
PPF: persist + Payments.retry(callbackUrl) / this.retry inside PaymentProvider
Standalone: POST notification payload (status + card fragments)
→ authorize() returns approved|denied + cardBrand/firstDigits/lastDigits
→ Sales App places the order
Common failure modes
- Generating an ecommerce PPP connector (Visa/Mastercard + Secure Proxy) when the user asked for PPP + POS.
- Omitting
Venda Direta Credito/Venda Direta Debitofrom the manifest, or using accented Admin labels as API names. - Returning
approvedbefore the terminal finishes, or waiting inside Create Payment for the card to be inserted. - Using
vtex.challenge-terminal-connector-appinstead ofvtex.terminal-connector-app. - Sending
paymentAppData.payloadas an object instead ofJSON.stringify(...), or hiding that withas AuthorizationResponseon a pending body. - Setting
delayToCancelbelowsecondsWaiting, so the Gateway cancels a payment the Wait app is still polling. - Starting the POS charge in the
submitUrlhandler (POS guide step 8.b starts it on the following Create Payment). - Starting a new POS charge on every Create Payment poll (broken idempotency).
- POSTing
callbackUrlon pending responses to "nudge" the Gateway. Sales App and Wait for confirmation already drive Create Payment. - Using
getJSON+saveJSONwith noetagwhile Create Payment calls overlap for onepaymentId. - Using a VBase bucket that makes
{vendor}.{appName}.{bucket}longer than 50 characters (default topos). - Skipping the Gateway callback, or omitting
cardBrand,firstDigits, andlastDigits. - On VTEX IO: POSTing a standalone notification payload (
status+ card fields) tocallbackUrlinstead of a bodilessPayments.retry/this.retry. - Copying the example's Test Suite
this.callback(req, resp)as the POS processor webhook. - Calling
this.retryfrom aservice.jsonextra route (noPaymentProviderthis). Usenew Payments(ctx.vtex).retry(callbackUrl)there. - Putting
submitUrlon PPP inbound-request instead of a public custom route. - Applying
payment-pci-securitySecure Proxy to POS authorize because "it is a card payment". - Building a Sales App cart/PDP extension instead of a payment connector.
- Starting from an empty Express app when the partner can use PPF / the official example app.
Review checklist
- For PPF, was the app overlaid on the official PPF example (or an existing PPF connector), not an empty HTTP server?
- Does
/manifest(and IOconfiguration.json) declareVenda Direta Creditoand/orVenda Direta Debitowith those exact names? - Does authorize branch with
isDirectSaleAuthorization(PPF) or the exact ASCII method names (standalone), and skip Gateway card PAN / Secure Proxy for POS? - Is the first POS response
Authorizations.pending+vtex.terminal-connector-appwith stringpayload.submitUrl(no whole-response cast)? - Is
submitUrla public custom route (service.json) that accepts{"serialNumber": "..."}and is not inbound-request? - Does
submitUrlonly persist the serial (step 7), and does the next Create Payment start the processor charge once (step 8.b)? - Is the waiting response
undefined+vtex.challenge-wait-for-confirmationwithsecondsWaiting? - Is
delayToCancel >= secondsWaiting, and does every endpoint respond in under 20 seconds? - Are overlapping Create Payment calls for the same
paymentIdsafe (getRawJSON+ifMatch, no second POS charge, no retry-on-pending)? - Is the PPF VBase bucket short enough that
{vendor}.{appName}.{bucket}is ≤ 50 characters (usepos)? - PPF: processor webhook persists card fragments then
Payments.retry(callbackUrl)(orthis.retryinsidePaymentProvider)? Nextauthorize()returns the card fragments (cast only that final response)? - Standalone: notification POST includes
status,cardBrand,firstDigits,lastDigits? - Is Cancel implemented so Gateway timeout can abort an
undefinedPOS payment? - Has the partner been told they still need a Sales App device, POS terminal, test account, payment conditions, and homologation?
Testing prerequisites (cannot be automated by the skill)
VTEX often provides a preconfigured store for connector testing. To configure a store manually:
- Install the connector (PPF: follow the Payment Provider Framework install steps).
- Register a Gateway affiliation for the connector.
- Create payment conditions Venda Direta Crédito and/or Venda Direta Débito on that affiliation.
- Show those methods on Sales App (
checkout-instore-custom.js). Standard condition IDs are44(debit) and45(credit); confirm on the Admin Payment Conditions page. - Install VTEX Sales App on a device and complete a purchase on a real or certified test POS.
- Run the Payment Provider Test Suite, including async approved/denied cases, then open a homologation ticket when required.
Related skills
Companions for this skill:
- generation.md — Questions to ask, clone-the-example workflow, implementation order
- ppf-scaffold.md — Overlay files for the official PPF example app
- examples.md — Partner prompts and IO vs standalone payloads
Other payment / Sales App skills:
payment-provider-protocol— Mandatory PPP endpoints and response shapes; use together with this skillpayment-provider-framework— Use when the POS connector is a VTEX IO apppayment-async-flow— Genericundefined+callbackUrlrules; POS adds Payment Apps and card-fragment callbacks on toppayment-idempotency—paymentIdmust not create a second POS charge; Create Payment for one id can overlap (etag /ifMatch)payment-pci-security— Ecommerce card-not-present only; do not apply Secure Proxy to POS authorizesales-app-extensibility— Sales App UI extensions; not the POS payment connector
Reference
- Payment Provider Protocol for Point of Sale (POS) - VTEX Sales App — Manifest methods, two-phase Payment Apps, timeouts, callback card fields, testing
- Payment App —
paymentAppData.appNameandpayloadcontract - Payment Provider Framework — Clone the official example app (Getting started), IO connector implementation,
this.retry, extra routes - Payment Provider Protocol (Help Center) — Callback URL, notification payload including
cardBrand/firstDigits/lastDigits - Define payment methods displayed on VTEX Sales App — Sales App payment condition filters (
44debit,45credit) - Payment provider homologation — Test Suite and publishing
- Registering gateway affiliations — Affiliation setup for tests
- Configuring payment conditions — Admin conditions for Venda Direta