Loading...
Loading...
Apply when implementing a VTEX Payment Provider Protocol connector for Point of Sale (POS) payments on VTEX Sales App. Covers Venda Direta Credito and Venda Direta Debito manifest methods, the two-phase Payment App challenge (terminal connector then wait-for-confirmation), null card data on Create Payment, asynchronous undefined status, timeout pairing of secondsWaiting and delayToCancel, and callback payloads with cardBrand, firstDigits, and lastDigits. Use for building or debugging PPP + POS integrations that charge a physical card on a payment terminal.
npx skill4agent add vtex/skills payment-ppp-posVenda Direta CreditoVenda Direta Debito| 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. |
payment-provider-protocolPaymentProviderconfiguration.jsonthis.retrypayment-provider-frameworkpayment-async-flowpayment-pci-securitysales-app-extensibilityauthorize()callbackUrl| Stored phase | Who drives the next Create Payment | Create Payment returns |
|---|---|---|
| (none yet) | Sales App checkout → Gateway (step 3) | Persist |
| Gateway / Sales App | Same terminal app until |
| Sales App after the terminal app closes (step 8) | Start the processor charge once; |
| Wait for confirmation polling (step 9) | Same wait app; no second charge; no retry POST |
| Wait app / Sales App after IO retry | Final status + |
Venda Direta CreditoVenda Direta Debitostatus: "undefined"callbackUrlpaymentMethodisDirectSaleAuthorization@vtex/payment-providerDirectSalecardsecureProxyUrlcardnullvtex.terminal-connector-appvtex.challenge-wait-for-confirmationvtex.challenge-terminal-connector-appvtex.terminal-connector-apppaymentAppData.payloadAuthorizations.pendingas AuthorizationResponsecardBrandfirstDigitslastDigits@vtex/payment-providerapproveddeniedcallbackUrlcallbackUrlPaymentProviderthis.retry(request)service.jsonnew Payments(ctx.vtex).retry(callbackUrl)this.retrystatuscardBrandauthorize()this.callback(req, resp)delayToCancelsecondsWaitingPartnerPospartnernameconnector-partnerposprovider.example.comSECONDS_WAITING = 600firstDigitslastDigits4111111111600delayToCancel >= secondsWaiting/manifestpaymentProvider/configuration.jsonpaymentMethodsVenda Direta CreditoVenda Direta DebitoVenda Direta CréditoVenda Direta DébitoVisaMastercard"POS Credit"paymentMethodsVenda Direta CreditoVenda Direta Debito{
"paymentMethods": [
{ "name": "Venda Direta Debito", "allowsSplit": "onCapture" },
{ "name": "Venda Direta Credito", "allowsSplit": "onCapture" }
]
}{
"paymentMethods": [
{ "name": "Visa", "allowsSplit": "onCapture" },
{ "name": "Mastercard", "allowsSplit": "onCapture" },
{ "name": "POS Credit", "allowsSplit": "onCapture" }
]
}paymentMethodVenda Direta CreditoVenda Direta Debitocard.holdercard.numbercard.csccard.expirationcard.documentcard.tokennullisDirectSaleAuthorization@vtex/payment-providerDirectSaleDirectSaleAuthorizationcardsecureProxyUrlauthorization.cardPOS_METHODS(authorization as any).paymentMethodpaymentMethodcallbackUrlvalueAuthorizationcard.numbersecureProxyUrldeniedcardas anypaymentMethodcallbackUrlvalueimport {
isDirectSaleAuthorization,
AuthorizationRequest,
} from "@vtex/payment-provider";
async function authorize(request: AuthorizationRequest) {
if (isDirectSaleAuthorization(request)) {
return authorizePos(request);
}
return authorizeEcommerce(request);
}async function authorize(request: CreatePaymentRequest) {
if (!request.card?.number) {
return { status: "denied", message: "Missing card number" };
}
return secureProxy.charge(request.card, request.secureProxyUrl);
}paymentIdstatus: "undefined"paymentAppDatavtex.terminal-connector-apppayloadsubmitUrl{"serialNumber": "12345"}callbackUrlpaymentIdAuthorizations.pending(authorization, { delayToCancel, paymentAppData })AppData.payloadMaybe<string>as AuthorizationResponsepayloadapproveddeniedpaymentAppDatavtex.challenge-terminal-connector-apppayloadas AuthorizationResponseimport { 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);
}return {
paymentId: request.paymentId,
status: "approved",
paymentAppData: {
appName: "vtex.challenge-terminal-connector-app",
payload: {
submitUrl: "https://provider.example.com/pos/serial",
},
},
};vtex.challenge-wait-for-confirmationsubmitUrl{"serialNumber": "..."}paymentIdstatus: "undefined"vtex.challenge-wait-for-confirmation{"secondsWaiting": <int>}callbackUrlauthorize()secondsWaitingdelayToCancelundefineddelayToCancel >= secondsWaitingsubmitUrldelayToCancelsecondsWaitingstartPosChargesecondsWaitingdelayToCancel < secondsWaitingasync 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 }),
},
});
}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 }),
},
};
}status"approved""denied"cardBrandfirstDigitslastDigitscardfirstDigitslastDigitscallbackUrlauthorize()approvedfetchcallbackUrlstatuscardBrandPayments.retrythis.retrythis.retrythisnew Payments(ctx.vtex).retry(stored.callbackUrl)authorize()callbackUrlimport { 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);PaymentProviderreturn this.retry(authorization);authorize()statuscardBrandfirstDigitslastDigitsasync 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",
}),
});paymentIdundefinedrequestIdpaymentIdgetRawJSONetagsaveJSON(..., ifMatch)payment-idempotencytidrequestId{vendor}.{appName}.{bucket}400 Metadata bucket name too largepospartnername.connector-partnerpospos-paymentsvendor.appNameidentify-terminalawait-pospaymentIdtidawaitprocessor.startPosChargephaseawait-posgetJSONsaveJSONetag{vendor}.{appName}.{bucket}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}`,
}),
},
});
}async function authorizePos(request: CreatePaymentRequest) {
const result = await processor.startPosCharge(request);
await processor.waitUntilCardFinished(result, { timeoutMs: 120000 });
return { paymentId: request.paymentId, status: result.status };
}vtex.terminal-connector-app{"serialNumber":"..."}submitUrlservice.jsonroutesPaymentProviderServicePOST /payments/{paymentId}/inbound-request/{action}submitUrlsubmitUrl/inboundservice.json{
"routes": {
"posSerial": {
"path": "/_v/partnerpos/pos/serial/:paymentId",
"public": true
}
}
}{
"submitUrl": "https://account.myvtex.com/_v/api/my-connector/payments/PAY1/inbound/serial"
}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 orderVenda Direta CreditoVenda Direta Debitoapprovedvtex.challenge-terminal-connector-appvtex.terminal-connector-apppaymentAppData.payloadJSON.stringify(...)as AuthorizationResponsedelayToCancelsecondsWaitingsubmitUrlcallbackUrlgetJSONsaveJSONetagpaymentId{vendor}.{appName}.{bucket}poscardBrandfirstDigitslastDigitsstatuscallbackUrlPayments.retrythis.retrythis.callback(req, resp)this.retryservice.jsonPaymentProviderthisnew Payments(ctx.vtex).retry(callbackUrl)submitUrlpayment-pci-security/manifestconfiguration.jsonVenda Direta CreditoVenda Direta DebitoisDirectSaleAuthorizationAuthorizations.pendingvtex.terminal-connector-apppayload.submitUrlsubmitUrlservice.json{"serialNumber": "..."}submitUrlundefinedvtex.challenge-wait-for-confirmationsecondsWaitingdelayToCancel >= secondsWaitingpaymentIdgetRawJSONifMatch{vendor}.{appName}.{bucket}posPayments.retry(callbackUrl)this.retryPaymentProviderauthorize()statuscardBrandfirstDigitslastDigitsundefinedcheckout-instore-custom.js4445payment-provider-protocolpayment-provider-frameworkpayment-async-flowundefinedcallbackUrlpayment-idempotencypaymentIdifMatchpayment-pci-securitysales-app-extensibilitypaymentAppData.appNamepayloadthis.retrycardBrandfirstDigitslastDigits4445