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 SPTSetting Up Stripe
Install the Stripe SDK
npm install stripeConfigure 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_secretPayment 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 Number | Result |
|---|---|
4242424242424242 | Success |
4000000000000002 | Decline |
4000000000009995 | Insufficient funds |
4000000000000069 | Expired 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:
- Testing Your Integration - Full test coverage
- API Reference - Complete API documentation
- Security Guide - Security best practices