Hands In Connector API Framework
Get MarkdownThis guide walks you through building a payment connector that integrates with the Hands In platform. By the end, you'll have a fully functional connector that can process payments through a unified transaction-based lifecycle.
What is a Connector?
A connector is a service that bridges payment providers with the Hands In platform. It translates between Hands In's standardized API and your payment provider's specific API format, supporting the complete payment lifecycle from initialization to completion.
Why Connectors?
- Standardization: All payment providers use the same interface with Hands In
- Unified Lifecycle: Single transaction ID flows through all operations
- Reliability: Built-in status polling ensures no missed payments
- Security: Centralized authentication and credential management
- Flexibility: Support any payment provider, regardless of their API design
New Transaction-Based Architecture
The updated connector framework uses a unified transaction lifecycle where a single transaction_id flows through all operations:
Core Endpoints
Every connector must implement these essential endpoints:
1. /info - Connector Information
Purpose: Tells Hands In about your connector's capabilities and configuration
GET /info
// Returns connector metadata, supported features, and polling configuration2. /initialize - Initialize Transaction
Purpose: Creates a new transaction with metadata and returns a transaction ID
POST /initialize
// Creates a transaction context and returns transaction_id for subsequent operations3. /authenticate - Authenticate Payment Method
Purpose: Performs 3D Secure, SCA, or other authentication challenges
POST /authenticate
// Authenticates the payment method (3DS, SCA, etc.)4. /authorize - Authorize Payment
Purpose: Creates an authorization (hold) on the customer's payment method
POST /authorize
// Authorizes funds using transaction_id from authenticate5. /capture - Capture Funds
Purpose: Captures (charges) a previously authorized payment
POST /capture
// Captures funds using transaction_id from authorize6. /void - Cancel Authorization
Purpose: Cancels a previously created authorization
POST /void
// Voids authorization using transaction_id7. /refund - Process Refunds
Purpose: Creates refunds for completed payments
POST /refund
// Creates a refund using transaction_id8. /status/{transactionId} - Check Transaction Status
Purpose: Returns comprehensive status for any transaction (replaces individual status endpoints)
GET /status/txn_1234567890
// Returns unified status with captured, authorized, and refunded amountsHow Status Synchronization Works
Hands In uses smart polling with the unified status endpoint to stay synchronized:
Smart Polling Schedule:
- Initial: Every 2 seconds (for quick payments)
- Backoff: Every 5 seconds, then 30 seconds
- Stop: When status reaches "succeeded", "failed", or "cancelled"
- Timeout: After 5 minutes maximum
Step-by-Step Implementation Guide
Step 1: Set Up Your Project
First, choose your technology stack and create the basic structure:
# Using Node.js/TypeScript (recommended)
npx create-express-app my-connector --typescript
cd my-connector
# Install dependencies
npm install express cors helmet dotenv
npm install -D @types/express @types/node typescript ts-nodeProject Structure:
Step 2: Implement Connector Information
Create your connector's information endpoint:
// src/routes/connector.ts
import { Router } from 'express';
const router = Router();
router.get('/info', (req, res) => {
res.json({
name: "MyProvider Connector",
description: "Payment processing connector for MyProvider",
icon: "https://myprovider.com/logo.png",
website: "https://myprovider.com",
// Required endpoints with new unified structure
endpoints: {
initialize: "/initialize",
authenticate: "/authenticate",
authorize: "/authorize",
void: "/void",
capture: "/capture",
refund: "/refund",
status: "/status/{transactionId}",
hooks: "/hooks"
},
// Authentication configuration
authConfig: {
type: "api_key",
fields: [
{
key: "api_key",
label: "API Key",
type: "password",
required: true,
helpText: "Your API key from MyProvider dashboard"
}
]
},
// Polling configuration
polling: {
enabled: true,
intervals: {
initial: 2, // 2 seconds initially
backoff: 5, // then 5 seconds
maximum: 30, // max 30 seconds
timeout: 300 // stop after 5 minutes
},
terminal_statuses: ["succeeded", "failed", "cancelled"]
},
// Supported features
supports: {
currencies: ["USD", "EUR", "GBP"],
countries: ["US", "CA", "GB"],
cards: ["visa", "mastercard", "amex"],
refunds: true,
partial_refunds: true,
fraud_detection: false
},
version: "1.0.0"
});
});
export default router;Step 3: Implement Authentication
Create authentication middleware to handle credentials:
// src/utils/auth.ts
export interface Credentials {
api_key: string;
// Add other fields as needed
}
export function extractAuth(req: any, res: any, next: any) {
try {
// Hands In sends credentials in request body
const credentials: Credentials = req.body.credentials;
if (!credentials || !credentials.api_key) {
return res.status(401).json({
error: {
type: "authentication_error",
code: "missing_credentials",
message: "API key is required"
}
});
}
// Attach credentials to request for use in handlers
req.credentials = credentials;
next();
} catch (error) {
res.status(401).json({
error: {
type: "authentication_error",
code: "invalid_credentials",
message: "Invalid authentication format"
}
});
}
}Step 4: Implement Transaction Lifecycle
4.1 Initialize Transaction
// src/routes/connector.ts (continued)
router.post('/initialize', extractAuth, async (req, res) => {
try {
const { amount, payment_method, metadata } = req.body;
const credentials = req.credentials;
// Create transaction context with your provider
const result = await paymentService.initializeTransaction({
amount: amount.amount,
currency: amount.currency,
payment_method,
metadata,
api_key: credentials.api_key
});
// Return standardized response with transaction_id
res.status(201).json({
transaction_id: result.transaction_id, // This will be used in all subsequent operations
status: mapStatus(result.status),
amount: amount,
created_at: new Date().toISOString(),
gateway_reference: result.provider_id,
metadata: metadata
});
} catch (error) {
handleError(res, error);
}
});4.2 Authenticate Payment Method
// src/routes/connector.ts (continued)
router.post('/authenticate', extractAuth, async (req, res) => {
try {
const { amount, payment_method, metadata } = req.body;
const credentials = req.credentials;
// Perform 3D Secure or SCA authentication
const result = await paymentService.authenticatePayment({
amount: amount.amount,
currency: amount.currency,
payment_method,
api_key: credentials.api_key
});
res.status(201).json({
id: result.transaction_id,
status: mapStatus(result.status),
amount: amount,
created_at: new Date().toISOString(),
gateway_reference: result.provider_id,
authentication_method: result.auth_method,
liability_shift: result.liability_shift
});
} catch (error) {
handleError(res, error);
}
});4.3 Authorize Payment
// src/routes/connector.ts (continued)
router.post('/authorize', extractAuth, async (req, res) => {
try {
const { transaction_id, amount, metadata } = req.body;
const credentials = req.credentials;
// Authorize using the transaction_id from previous step
const result = await paymentService.authorizePayment({
transaction_id,
amount: amount?.amount,
currency: amount?.currency,
api_key: credentials.api_key
});
res.status(201).json({
id: result.authorization_id,
status: mapStatus(result.status),
amount: result.amount,
created_at: new Date().toISOString(),
original_transaction_id: transaction_id,
gateway_reference: result.provider_id,
expires_at: result.expires_at
});
} catch (error) {
handleError(res, error);
}
});4.4 Capture Funds
// src/routes/connector.ts (continued)
router.post('/capture', extractAuth, async (req, res) => {
try {
const { transaction_id, amount, metadata } = req.body;
const credentials = req.credentials;
// Capture using the transaction_id
const result = await paymentService.capturePayment({
transaction_id,
amount: amount?.amount, // Optional for partial capture
currency: amount?.currency,
api_key: credentials.api_key
});
res.status(201).json({
id: result.capture_id,
status: mapStatus(result.status),
amount: result.amount,
created_at: new Date().toISOString(),
original_transaction_id: transaction_id,
gateway_reference: result.provider_id
});
} catch (error) {
handleError(res, error);
}
});4.5 Void Authorization
// src/routes/connector.ts (continued)
router.post('/void', extractAuth, async (req, res) => {
try {
const { transaction_id, reason, metadata } = req.body;
const credentials = req.credentials;
// Void the authorization
const result = await paymentService.voidPayment({
transaction_id,
reason,
api_key: credentials.api_key
});
res.status(201).json({
id: result.void_id,
status: mapStatus(result.status),
created_at: new Date().toISOString(),
original_transaction_id: transaction_id,
gateway_reference: result.provider_id,
reason: reason
});
} catch (error) {
handleError(res, error);
}
});4.6 Process Refunds
// src/routes/connector.ts (continued)
router.post('/refund', extractAuth, async (req, res) => {
try {
const { transaction_id, amount, reason } = req.body;
const credentials = req.credentials;
// Create refund using transaction_id
const result = await paymentService.refundPayment({
transaction_id,
amount: amount?.amount, // Optional for partial refund
currency: amount?.currency,
reason,
api_key: credentials.api_key
});
res.status(201).json({
id: result.refund_id,
status: mapStatus(result.status),
amount: result.amount,
created_at: new Date().toISOString(),
original_transaction_id: transaction_id,
gateway_reference: result.provider_id
});
} catch (error) {
handleError(res, error);
}
});Step 5: Implement Unified Status Checking
The most important endpoint - provides comprehensive status for any transaction:
// src/routes/connector.ts (continued)
router.get('/status/:transactionId', extractAuth, async (req, res) => {
try {
const { transactionId } = req.params;
const credentials = req.credentials;
// Get comprehensive status from provider
const transaction = await paymentService.getTransactionStatus(transactionId, {
api_key: credentials.api_key
});
// Return unified status response
res.json({
transaction_id: transactionId,
status: mapStatus(transaction.status), // One of: capturable, requires_action, processing, cancelled, succeeded
// Money tracking - all amounts for this transaction
authorized_money: {
amount: transaction.authorized_amount || 0,
currency: transaction.currency
},
captured_money: {
amount: transaction.captured_amount || 0,
currency: transaction.currency
},
refunded_money: {
amount: transaction.refunded_amount || 0,
currency: transaction.currency
},
// Next action details (for redirects, app opens, etc.)
next_action: transaction.requires_action ? {
type: transaction.action_type, // "redirect", "app_redirect", "webhook_wait", "none"
redirect_url: transaction.redirect_url,
app_url: transaction.app_url
} : null,
// Timestamps and metadata
created_at: transaction.created_at,
updated_at: transaction.updated_at,
expiry_date: transaction.expires_at,
gateway_reference: transaction.provider_id,
metadata: transaction.metadata
});
} catch (error) {
handleError(res, error);
}
});Step 6: Implement Lifecycle Hooks
Handle pre/post operation events:
// src/routes/connector.ts (continued)
router.post('/hooks', extractAuth, async (req, res) => {
try {
const { event, transaction_id, data } = req.body;
const credentials = req.credentials;
let result;
switch (event) {
case 'pre_authenticate':
result = await handlePreAuthenticate(transaction_id, data, credentials);
break;
case 'post_authenticate':
result = await handlePostAuthenticate(transaction_id, data, credentials);
break;
case 'pre_authorize':
result = await handlePreAuthorize(transaction_id, data, credentials);
break;
case 'post_authorize':
result = await handlePostAuthorize(transaction_id, data, credentials);
break;
case 'pre_capture':
result = await handlePreCapture(transaction_id, data, credentials);
break;
case 'post_capture':
result = await handlePostCapture(transaction_id, data, credentials);
break;
case 'pre_void':
result = await handlePreVoid(transaction_id, data, credentials);
break;
case 'post_void':
result = await handlePostVoid(transaction_id, data, credentials);
break;
case 'pre_refund':
result = await handlePreRefund(transaction_id, data, credentials);
break;
case 'post_refund':
result = await handlePostRefund(transaction_id, data, credentials);
break;
default:
return res.status(400).json({
success: false,
error: {
type: "validation_error",
code: "unsupported_event",
message: `Event type '${event}' is not supported`
}
});
}
res.json({
success: true,
data: result
});
} catch (error) {
res.status(500).json({
success: false,
error: {
type: "server_error",
code: "hook_execution_failed",
message: error.message
}
});
}
});Step 7: Essential Utility Functions
Status Mapping
Map your provider's statuses to Hands In standard statuses:
// src/utils/status.ts
export function mapStatus(providerStatus: string): string {
const statusMap: Record<string, string> = {
// Provider statuses -> Hands In statuses
'pending': 'processing',
'authorized': 'capturable',
'requires_payment_method': 'requires_action',
'requires_action': 'requires_action',
'processing': 'processing',
'succeeded': 'succeeded',
'paid': 'succeeded',
'completed': 'succeeded',
'failed': 'cancelled',
'canceled': 'cancelled',
'voided': 'cancelled'
};
return statusMap[providerStatus.toLowerCase()] || 'processing';
}Error Handling
// src/utils/errors.ts
export function handleError(res: any, error: any) {
console.error('Payment operation failed:', error);
// Map provider errors to standard format
if (error.code === 'card_declined') {
return res.status(402).json({
error: {
type: "payment_error",
code: "card_declined",
message: "Your card was declined",
decline_code: error.decline_code
}
});
}
if (error.code === 'insufficient_funds') {
return res.status(402).json({
error: {
type: "payment_error",
code: "card_declined",
message: "Insufficient funds",
decline_code: "insufficient_funds"
}
});
}
// Generic error
res.status(500).json({
error: {
type: "server_error",
code: "processing_error",
message: "Payment processing failed"
}
});
}Complete Payment Flow Example
Here's how a complete payment flows through your connector:
Testing Your Connector
1. Test Transaction Lifecycle
# 1. Initialize transaction
curl -X POST http://localhost:3000/initialize \
-H "Content-Type: application/json" \
-d '{
"amount": { "amount": 1000, "currency": "USD" },
"payment_method": { "type": "card", "token": "tok_visa_4242" },
"credentials": { "api_key": "your_test_key" }
}'
# Response: { "transaction_id": "txn_123", "status": "succeeded" }
# 2. Authorize using transaction_id
curl -X POST http://localhost:3000/authorize \
-H "Content-Type: application/json" \
-d '{
"transaction_id": "txn_123",
"credentials": { "api_key": "your_test_key" }
}'
# 3. Check unified status
curl -X GET http://localhost:3000/status/txn_123 \
-H "Authorization: Bearer <your-api-key>"
# 4. Capture funds
curl -X POST http://localhost:3000/capture \
-H "Content-Type: application/json" \
-d '{
"transaction_id": "txn_123",
"credentials": { "api_key": "your_test_key" }
}'2. Test Error Scenarios
# Test declined card
curl -X POST http://localhost:3000/authorize \
-H "Content-Type: application/json" \
-d '{
"transaction_id": "txn_decline_test",
"credentials": { "api_key": "your_test_key" }
}'
# Expected: 402 Payment Error response3. Test Status Polling
# Check status every few seconds
watch -n 2 'curl -s http://localhost:3000/status/txn_123 -H "Authorization: Bearer <your-api-key>" | jq'Migration from Old API
If you have an existing connector using the old /charge and /payments/{id} structure, here's how to migrate:
Old Structure → New Structure
| Old Endpoint | New Endpoints | Notes |
|---|---|---|
POST /charge | POST /initialize → POST /authorize → POST /capture | Split into lifecycle steps |
GET /payments/{id} | GET /status/{transactionId} | Unified status endpoint |
POST /refund | POST /refund | Same, but uses transaction_id |
POST /cancel | POST /void | Renamed for clarity |
Migration Steps
- Update endpoint paths in your
/inforesponse - Implement
/initializeto create transaction context - Split charge logic across
initialize→authorize→capture - Update status endpoint to return unified format with money tracking
- Change ID parameter from
payment_idtotransaction_id - Test thoroughly with the new flow
Best Practices
1. Transaction ID Management
- Always use the transaction_id provided by Hands In across all operations
- Store mappings between Hands In transaction_ids and your provider's IDs
- Handle idempotency by checking if transaction_id already exists
2. Status Accuracy
- Map statuses precisely - incorrect status mapping breaks polling
- Include all money amounts in status responses (authorized, captured, refunded)
- Set proper next_action when customer interaction is required
3. Error Handling
- Use specific error codes to help merchants understand issues
- Include decline codes for payment failures
- Log errors thoroughly for debugging
4. Security
- Validate all credentials on every request
- Use HTTPS only in production
- Never log sensitive data (API keys, card details)
5. Performance
- Cache status responses when appropriate
- Use connection pooling for provider API calls
- Handle rate limits gracefully
Deployment Checklist
Before deploying your connector:
- All 8 core endpoints implemented and tested
- Status mapping covers all provider states
- Error handling for common scenarios
- Logging configured (no sensitive data)
- HTTPS configured with valid certificates
- Health check endpoint (
/health) implemented - Environment variables configured
- Rate limiting implemented
- Monitoring and alerting set up
- Documentation updated with your provider details
Next Steps
- Register your connector in the Hands In dashboard
- Configure authentication with your provider credentials
- Test in sandbox environment first
- Monitor performance and error rates
- Scale as needed based on transaction volume
Support
Need help building your connector?
- 📧 Email: tech@handsin.com
- 📖 API Reference: Hands In Merchant API reference
- 💬 Discord: Join our developer community
- 🐛 Issues: Contact support with the failing request and response
System Architecture
Here's how your connector fits into the overall Hands In ecosystem:
Example: Stripe Connector
Here's a complete example implementing a Stripe connector using Node Express:
Main Application
// src/index.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import handsInRoutes from './routes/handsin';
const app = express();
app.use(helmet());
app.use(cors());
app.use(express.json());
// Mount Hands In routes
app.use('/handsin', handsInRoutes);
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Stripe connector running on port ${PORT}`);
});
export default app;Stripe Service
// src/services/stripeService.ts
import Stripe from 'stripe';
export class StripeService {
private stripe: Stripe;
constructor(apiKey: string) {
this.stripe = new Stripe(apiKey, { apiVersion: '2023-10-16' });
}
async createPayment(params: {
amount: number;
currency: string;
payment_method: any;
metadata?: any;
}) {
const paymentIntent = await this.stripe.paymentIntents.create({
amount: params.amount,
currency: params.currency,
payment_method: params.payment_method.token,
confirmation_method: 'manual',
confirm: true,
metadata: params.metadata || {}
});
return {
id: paymentIntent.id,
status: paymentIntent.status,
amount: paymentIntent.amount,
currency: paymentIntent.currency,
created_at: new Date(paymentIntent.created * 1000).toISOString()
};
}
async getPayment(paymentId: string) {
try {
const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentId);
return {
id: paymentIntent.id,
status: paymentIntent.status,
amount: paymentIntent.amount,
currency: paymentIntent.currency,
created_at: new Date(paymentIntent.created * 1000).toISOString(),
failure_reason: paymentIntent.last_payment_error?.message
};
} catch (error) {
if (error.code === 'resource_missing') {
const notFoundError = new Error('Payment not found');
(notFoundError as any).code = 'NOT_FOUND';
throw notFoundError;
}
throw error;
}
}
async refundPayment(paymentId: string, amount?: number) {
const refund = await this.stripe.refunds.create({
payment_intent: paymentId,
amount: amount
});
return {
id: refund.id,
status: refund.status,
amount: refund.amount,
created_at: new Date(refund.created * 1000).toISOString()
};
}
}Hands In Routes
// src/routes/handsin.ts
import { Router } from 'express';
import { StripeService } from '../services/stripeService';
import { extractAuth } from '../utils/auth';
const router = Router();
// Connector information
router.get('/info', (req, res) => {
res.json({
name: "Stripe Connector",
description: "Official Stripe payment processing connector for Hands In",
icon: "https://images.ctfassets.net/fzn2n1nzq965/3AGidihOJl4nH9D1vDjM80/7ca5e0d94956e353c81bbcf9b67b9bc9/stripe-logo-white.png",
website: "https://stripe.com",
endpoints: {
charge: "/handsin/charge",
get_payment: "/handsin/payments/{payment_id}",
refund: "/handsin/refund",
cancel: "/handsin/cancel"
},
authConfig: {
type: "api_key",
fields: [
{
key: "secret_key",
label: "Secret Key",
type: "password",
required: true,
helpText: "Your Stripe secret key (starts with sk_)",
placeholder: "sk_test_... or sk_live_..."
}
]
},
polling: {
enabled: true,
intervals: {
initial: 2,
backoff: 5,
maximum: 30,
timeout: 300
},
terminal_statuses: ["succeeded", "failed", "canceled"]
},
supports: {
currencies: ["USD", "EUR", "GBP", "CAD", "AUD", "JPY"],
countries: ["US", "CA", "GB", "AU", "DE", "FR", "IT", "ES", "NL", "SE"],
cards: ["visa", "mastercard", "amex", "discover", "diners", "jcb"],
apms: ["apple_pay", "google_pay", "link", "klarna", "afterpay"],
refunds: true,
partial_refunds: true,
fraud_detection: true,
three_d_secure: true
},
version: process.env.CONNECTOR_VERSION || "1.0.0"
});
});
// Process payment
router.post('/charge', extractAuth, async (req, res) => {
try {
const { amount, payment_method, metadata } = req.body;
const { secret_key } = req.credentials;
const stripeService = new StripeService(secret_key);
const result = await stripeService.createPayment({
amount: amount.amount,
currency: amount.currency,
payment_method,
metadata
});
res.status(201).json({
id: result.id,
status: mapStripeStatus(result.status),
amount: { amount: result.amount, currency: result.currency },
created_at: result.created_at,
gateway_reference: result.id,
metadata
});
} catch (error) {
handleStripeError(res, error);
}
});
// Check payment status
router.get('/payments/:payment_id', extractAuth, async (req, res) => {
try {
const { payment_id } = req.params;
const { secret_key } = req.credentials;
const stripeService = new StripeService(secret_key);
const payment = await stripeService.getPayment(payment_id);
res.json({
id: payment.id,
status: mapStripeStatus(payment.status),
amount: { amount: payment.amount, currency: payment.currency },
created_at: payment.created_at,
gateway_reference: payment.id,
failure_reason: payment.failure_reason
});
} catch (error) {
if (error.code === 'NOT_FOUND') {
return res.status(404).json({
error: {
type: "not_found",
code: "payment_not_found",
message: `Payment ${req.params.payment_id} not found`
}
});
}
handleStripeError(res, error);
}
});
// Process refund
router.post('/refund', extractAuth, async (req, res) => {
try {
const { payment_id, amount } = req.body;
const { secret_key } = req.credentials;
const stripeService = new StripeService(secret_key);
const result = await stripeService.refundPayment(payment_id, amount?.amount);
res.status(201).json({
id: result.id,
status: mapStripeStatus(result.status),
amount: { amount: result.amount, currency: 'usd' }, // Stripe doesn't return currency
created_at: result.created_at,
original_payment_id: payment_id
});
} catch (error) {
handleStripeError(res, error);
}
});
// Map Stripe statuses to standard statuses
function mapStripeStatus(stripeStatus: string): string {
const statusMap = {
'succeeded': 'completed',
'failed': 'failed',
'canceled': 'cancelled',
'requires_payment_method': 'failed',
'requires_confirmation': 'pending',
'requires_action': 'pending',
'processing': 'pending',
'requires_capture': 'pending'
};
return statusMap[stripeStatus] || 'pending';
}
// Handle Stripe-specific errors
function handleStripeError(res: any, error: any) {
console.error('Stripe error:', error);
if (error.type === 'StripeCardError') {
return res.status(402).json({
error: {
type: "card_error",
code: "card_declined",
message: error.message,
details: {
decline_code: error.decline_code,
param: error.param
}
}
});
}
if (error.type === 'StripeInvalidRequestError') {
return res.status(400).json({
error: {
type: "validation_error",
code: "invalid_request",
message: error.message,
details: {
param: error.param
}
}
});
}
// Default error
res.status(500).json({
error: {
type: "api_error",
code: "internal_error",
message: "An error occurred processing your request"
}
});
}
export default router;Authentication Utility
// src/utils/auth.ts
export interface StripeCredentials {
secret_key: string;
}
export function extractAuth(req: any, res: any, next: any) {
try {
const credentials: StripeCredentials = req.body.credentials;
if (!credentials || !credentials.secret_key) {
return res.status(401).json({
error: {
type: "authentication_error",
code: "missing_credentials",
message: "Stripe secret key is required"
}
});
}
if (!credentials.secret_key.startsWith('sk_')) {
return res.status(401).json({
error: {
type: "authentication_error",
code: "invalid_credentials",
message: "Invalid Stripe secret key format"
}
});
}
req.credentials = credentials;
next();
} catch (error) {
res.status(401).json({
error: {
type: "authentication_error",
code: "invalid_credentials",
message: "Invalid authentication format"
}
});
}
}