Skip to Content
🚀Agentic Commerce Protocol is now live! Instant Checkout is available in ChatGPT. Learn more →
DocumentationStep-by-Step TutorialsHandling Payment Tokens

Handling Payment Tokens

Shared Payment Tokens (SPTs) are the secure payment mechanism in ACP. This tutorial covers how to integrate with Stripe to process SPTs and complete transactions securely.

Understanding SPTs

Shared Payment Tokens are:

  • Merchant-specific: Can only be used by the intended merchant
  • Amount-limited: Cannot charge more than authorized
  • Time-limited: Expire after ~30 minutes
  • Single-use: Cannot be charged twice
User → Authorizes payment → Stripe creates SPT → AI Agent passes SPT → Merchant charges SPT

Setting Up Stripe

Install the Stripe SDK

npm install stripe

Configure Stripe

// config/stripe.js const Stripe = require('stripe'); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: '2023-10-16' }); module.exports = stripe;

Environment setup

# .env STRIPE_SECRET_KEY=sk_test_your_key STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret

Payment Service Implementation

// services/paymentService.js const stripe = require('../config/stripe'); const { v4: uuidv4 } = require('uuid'); const CheckoutSession = require('../models/session'); class PaymentService { /** * Process a payment using a Shared Payment Token */ async processPayment(session, paymentToken, idempotencyKey) { // Validate session state if (session.state !== 'ready_for_payment') { throw { type: 'invalid_state', message: `Session state is ${session.state}, expected ready_for_payment` }; } // Validate token format if (!this.isValidTokenFormat(paymentToken)) { throw { type: 'invalid_token', message: 'Invalid payment token format' }; } // Generate idempotency key if not provided const key = idempotencyKey || `${session.checkout_session_id}_${Date.now()}`; try { // Create payment intent with the SPT const paymentIntent = await stripe.paymentIntents.create({ amount: session.totals.total, currency: session.totals.currency.toLowerCase(), payment_method: paymentToken, confirm: true, automatic_payment_methods: { enabled: true, allow_redirects: 'never' }, metadata: { checkout_session_id: session.checkout_session_id, customer_email: session.buyer_context.email }, // For SPTs, we capture immediately capture_method: 'automatic', // Shipping info for fraud prevention shipping: session.buyer_context.shipping_address ? { name: session.buyer_context.name || 'Customer', address: { line1: session.buyer_context.shipping_address.line1, line2: session.buyer_context.shipping_address.line2, city: session.buyer_context.shipping_address.city, state: session.buyer_context.shipping_address.state, postal_code: session.buyer_context.shipping_address.postal_code, country: session.buyer_context.shipping_address.country } } : undefined }, { idempotencyKey: key }); // Check payment status if (paymentIntent.status !== 'succeeded') { throw { type: 'payment_failed', message: 'Payment was not successful', status: paymentIntent.status }; } // Update session with order info const orderId = `ord_${uuidv4().replace(/-/g, '').slice(0, 16)}`; session.state = 'completed'; session.order = { order_id: orderId, payment_intent_id: paymentIntent.id, created_at: new Date() }; await session.save(); return { order_id: orderId, payment_intent_id: paymentIntent.id, session }; } catch (error) { // Handle Stripe errors if (error.type === 'StripeCardError') { throw { type: 'payment_failed', message: error.message, decline_code: error.decline_code }; } if (error.type === 'StripeInvalidRequestError') { throw { type: 'invalid_token', message: 'Payment token is invalid or expired' }; } throw error; } } /** * Validate SPT format */ isValidTokenFormat(token) { // SPTs typically start with 'pm_' or 'spt_' return token && ( token.startsWith('pm_') || token.startsWith('spt_') || token.startsWith('pi_') ); } /** * Verify an SPT before processing */ async verifyToken(paymentToken) { try { const paymentMethod = await stripe.paymentMethods.retrieve(paymentToken); return { valid: true, type: paymentMethod.type, card: paymentMethod.card ? { brand: paymentMethod.card.brand, last4: paymentMethod.card.last4, exp_month: paymentMethod.card.exp_month, exp_year: paymentMethod.card.exp_year } : null }; } catch (error) { return { valid: false, error: error.message }; } } /** * Create a refund for a completed order */ async refundPayment(orderId, amount = null, reason = 'requested_by_customer') { // Find the session const session = await CheckoutSession.findOne({ 'order.order_id': orderId }); if (!session || !session.order?.payment_intent_id) { throw new Error('Order not found or payment not completed'); } const refund = await stripe.refunds.create({ payment_intent: session.order.payment_intent_id, amount: amount, // null = full refund reason: reason }); return { refund_id: refund.id, amount: refund.amount, status: refund.status }; } } module.exports = new PaymentService();

Handling Webhooks

Set up webhooks to handle async payment events:

// routes/webhooks.js const express = require('express'); const router = express.Router(); const stripe = require('../config/stripe'); const CheckoutSession = require('../models/session'); // Use raw body for webhook signature verification router.post('/stripe', express.raw({ type: 'application/json' }), async (req, res) => { const sig = req.headers['stripe-signature']; let event; try { event = stripe.webhooks.constructEvent( req.body, sig, process.env.STRIPE_WEBHOOK_SECRET ); } catch (err) { console.error('Webhook signature verification failed:', err.message); return res.status(400).send(`Webhook Error: ${err.message}`); } // Handle events switch (event.type) { case 'payment_intent.succeeded': await handlePaymentSuccess(event.data.object); break; case 'payment_intent.payment_failed': await handlePaymentFailure(event.data.object); break; case 'charge.refunded': await handleRefund(event.data.object); break; case 'charge.dispute.created': await handleDispute(event.data.object); break; default: console.log(`Unhandled event type: ${event.type}`); } res.json({ received: true }); }); async function handlePaymentSuccess(paymentIntent) { const sessionId = paymentIntent.metadata?.checkout_session_id; if (!sessionId) return; const session = await CheckoutSession.findOne({ checkout_session_id: sessionId }); if (session && session.state !== 'completed') { session.state = 'completed'; await session.save(); // Trigger fulfillment // await fulfillmentService.createOrder(session); } } async function handlePaymentFailure(paymentIntent) { const sessionId = paymentIntent.metadata?.checkout_session_id; if (!sessionId) return; console.log(`Payment failed for session ${sessionId}:`, paymentIntent.last_payment_error?.message); // Notify customer or retry logic } async function handleRefund(charge) { console.log(`Refund processed: ${charge.id}`); // Update order status, notify customer } async function handleDispute(dispute) { console.log(`Dispute created: ${dispute.id}`); // Alert merchant, gather evidence } module.exports = router;

Security Best Practices

🔐

Never log or store full payment tokens. Only store references like payment intent IDs.

Token Validation

// middleware/validatePayment.js const validatePaymentRequest = (req, res, next) => { const { payment_token } = req.body; // Check token exists if (!payment_token) { return res.status(400).json({ error: 'Missing payment token', code: 'payment_token_required' }); } // Check token format (basic validation) if (typeof payment_token !== 'string' || payment_token.length < 10) { return res.status(400).json({ error: 'Invalid payment token format', code: 'invalid_token_format' }); } // Check for suspicious patterns if (payment_token.includes(' ') || payment_token.includes('\n')) { return res.status(400).json({ error: 'Invalid payment token', code: 'malformed_token' }); } next(); }; module.exports = validatePaymentRequest;

Idempotency

Prevent duplicate charges:

// Idempotency implementation const completedPayments = new Map(); // Use Redis in production async function processWithIdempotency(sessionId, paymentToken, idempotencyKey) { const key = idempotencyKey || `payment_${sessionId}`; // Check if already processed if (completedPayments.has(key)) { return completedPayments.get(key); } // Process payment const result = await processPayment(sessionId, paymentToken); // Store result completedPayments.set(key, result); // Expire after 24 hours setTimeout(() => completedPayments.delete(key), 24 * 60 * 60 * 1000); return result; }

Testing Payments

Test Card Numbers

Use Stripe’s test cards:

Card NumberResult
4242424242424242Success
4000000000000002Decline
4000000000009995Insufficient funds
4000000000000069Expired card

Test Payment Flow

// test/payment.test.js const request = require('supertest'); const app = require('../app'); describe('Payment Processing', () => { let sessionId; beforeEach(async () => { // Create a test session const res = await request(app) .post('/acp/v1/checkout_sessions') .send({ cart: { items: [{ product_id: 'prod_001', quantity: 1 }] }, buyer_context: { email: 'test@example.com', shipping_address: { line1: '123 Test St', city: 'Test City', state: 'CA', postal_code: '94102', country: 'US' } } }); sessionId = res.body.checkout_session_id; }); test('successful payment', async () => { const res = await request(app) .post(`/acp/v1/checkout_sessions/${sessionId}/complete`) .send({ payment_token: 'pm_card_visa' // Stripe test token }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.order_id).toBeDefined(); }); test('declined card', async () => { const res = await request(app) .post(`/acp/v1/checkout_sessions/${sessionId}/complete`) .send({ payment_token: 'pm_card_chargeDeclined' }); expect(res.status).toBe(400); expect(res.body.error).toBe('Payment failed'); }); });

Error Handling

Handle all payment scenarios:

const handlePaymentError = (error) => { switch (error.type) { case 'StripeCardError': return { status: 400, error: 'card_error', message: getCardErrorMessage(error.code), decline_code: error.decline_code }; case 'StripeInvalidRequestError': return { status: 400, error: 'invalid_request', message: 'The payment request was invalid' }; case 'StripeAPIError': return { status: 500, error: 'api_error', message: 'Payment service temporarily unavailable' }; default: return { status: 500, error: 'unknown_error', message: 'An unexpected error occurred' }; } }; const getCardErrorMessage = (code) => { const messages = { 'card_declined': 'Your card was declined', 'expired_card': 'Your card has expired', 'incorrect_cvc': 'Incorrect security code', 'insufficient_funds': 'Insufficient funds', 'processing_error': 'Card processing error' }; return messages[code] || 'Card payment failed'; };

Next Steps

Now that you can process payments:

Specification maintained by OpenAI and Stripe

AboutPrivacyTermsRSS

Apache 2.0 · Open Source