Pre-Authorization (Auth & Capture)
Authorize customer payments upfront and capture funds later using Omise's two-step payment flow for flexible payment timing and order fulfillment.
Overviewโ
Pre-authorization (also called "auth and capture") is a two-step payment process where you first authorize a payment to verify funds availability and hold them, then capture the actual charge later when you're ready to settle. This is useful for made-to-order products, hotel reservations, rental services, and any scenario where fulfillment happens after purchase.
Key Concepts:
- Authorize - Verify card and hold funds (customer not charged yet)
- Capture - Collect the held funds (customer charged)
- Void - Cancel authorization before capture (funds released)
- Auto-capture - Default mode, authorizes and captures immediately
- Manual capture - Two-step process, requires explicit capture
When to Use Pre-Authorizationโ
Good Use Casesโ
โ Made-to-order products
- Authorize when order is placed
- Capture when item ships
- Void if item unavailable
โ Hotel reservations
- Authorize at booking
- Capture at check-out
- Adjust amount for incidentals
โ Rental services
- Authorize security deposit
- Capture damages or fees
- Release if no issues
โ Custom services
- Authorize estimated cost
- Capture actual final cost
- Account for scope changes
โ Inventory verification
- Authorize immediately
- Verify stock availability
- Capture if in stock, void if not
Not Recommendedโ
โ Digital goods - Deliver immediately, use auto-capture โ Subscriptions - Use regular charges (can't pre-auth recurring) โ Low-value items - Added complexity not worth it โ Immediate fulfillment - Use auto-capture
How It Worksโ
Implementationโ
Step 1: Create Authorizationโ
- cURL
- Node.js
- PHP
- Python
- Ruby
- Go
- Java
- C#
curl https://api.omise.co/charges \
-u skey_test_YOUR_SECRET_KEY: \
-d "amount=100000" \
-d "currency=THB" \
-d "card=tokn_test_..." \
-d "capture=false"
const omise = require('omise')({
secretKey: 'skey_test_YOUR_SECRET_KEY'
});
const charge = await omise.charges.create({
amount: 100000, // THB 1,000.00
currency: 'THB',
card: tokenId,
capture: false, // Pre-authorize only
description: 'Pre-auth for Order #12345',
metadata: {
order_id: '12345'
}
});
console.log('Charge status:', charge.status); // 'pending'
console.log('Authorized:', charge.authorized); // true
console.log('Paid:', charge.paid); // false
<?php
$charge = OmiseCharge::create(array(
'amount' => 100000,
'currency' => 'THB',
'card' => $tokenId,
'capture' => false
));
?>
import omise
omise.api_secret = 'skey_test_YOUR_SECRET_KEY'
charge = omise.Charge.create(
amount=100000,
currency='THB',
card=token_id,
capture=False
)
print('Status:', charge.status) # 'pending'
require 'omise'
Omise.api_key = 'skey_test_YOUR_SECRET_KEY'
charge = Omise::Charge.create({
amount: 100000,
currency: 'THB',
card: token_id,
capture: false
})
puts charge.status # 'pending'
charge, err := client.Charges().Create(&operations.CreateCharge{
Amount: 100000,
Currency: "THB",
Card: tokenId,
Capture: false,
})
Charge charge = client.charges().create(new Charge.CreateParams()
.amount(100000L)
.currency("THB")
.card(tokenId)
.capture(false));
var charge = await client.Charges.Create(new CreateChargeRequest
{
Amount = 100000,
Currency = "THB",
Card = tokenId,
Capture = false
});
Response:
{
"object": "charge",
"id": "chrg_test_5rt6s9vah5lkvi1rh9c",
"amount": 100000,
"currency": "THB",
"status": "pending",
"authorized": true,
"paid": false,
"capture": false,
"capturable": true,
"expires_at": "2025-02-13T00:00:00Z"
}
Step 2: Capture Fundsโ
// After order is fulfilled
const capture = await omise.charges.capture('chrg_test_5rt6s9vah5lkvi1rh9c');
console.log('Capture status:', capture.status); // 'successful'
console.log('Paid:', capture.paid); // true
Capture with different amount (if supported):
// Capture less than authorized (e.g., final bill lower)
const capture = await omise.charges.capture('chrg_test_...', {
capture_amount: 80000 // Only capture เธฟ800 of เธฟ1,000 authorized
});
Step 3: Or Reverse/Void Authorizationโ
// Cancel authorization if order is canceled
const reversed = await omise.charges.reverse('chrg_test_5rt6s9vah5lkvi1rh9c');
console.log('Status:', reversed.status); // 'reversed' or 'expired'
console.log('Reversed at:', reversed.reversed_at);
Complete Exampleโ
const express = require('express');
const omise = require('omise')({
secretKey: process.env.OMISE_SECRET_KEY
});
const app = express();
app.use(express.json());
// Step 1: Customer places order
app.post('/checkout', async (req, res) => {
try {
const { tokenId, amount, orderId } = req.body;
// Authorize payment (don't capture yet)
const charge = await omise.charges.create({
amount: amount,
currency: 'THB',
card: tokenId,
capture: false,
description: `Pre-auth for Order #${orderId}`,
metadata: {
order_id: orderId,
authorized_at: new Date().toISOString()
}
});
if (charge.status === 'pending' && charge.authorized) {
// Save to database
await db.orders.create({
order_id: orderId,
charge_id: charge.id,
status: 'authorized',
amount: amount,
expires_at: charge.expires_at
});
res.json({
success: true,
message: 'Payment authorized',
order_id: orderId
});
} else {
res.status(400).json({
error: 'Authorization failed',
reason: charge.failure_message
});
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Step 2: Fulfill order and capture
app.post('/orders/:orderId/ship', async (req, res) => {
try {
const { orderId } = req.params;
const { trackingNumber } = req.body;
// Get order
const order = await db.orders.findOne({ order_id: orderId });
if (order.status !== 'authorized') {
return res.status(400).json({
error: 'Order not in authorized state'
});
}
// Capture payment
const capture = await omise.charges.capture(order.charge_id);
if (capture.status === 'successful') {
// Update order
await db.orders.update({
order_id: orderId,
status: 'captured',
shipped: true,
tracking_number: trackingNumber,
captured_at: new Date()
});
res.json({
success: true,
message: 'Payment captured, order shipped'
});
} else {
res.status(400).json({
error: 'Capture failed'
});
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Step 3: Cancel order and release funds
app.post('/orders/:orderId/cancel', async (req, res) => {
try {
const { orderId } = req.params;
const { reason } = req.body;
const order = await db.orders.findOne({ order_id: orderId });
if (order.status !== 'authorized') {
return res.status(400).json({
error: 'Order cannot be canceled'
});
}
// Reverse authorization
const reversed = await omise.charges.reverse(order.charge_id);
// Update order
await db.orders.update({
order_id: orderId,
status: 'canceled',
cancellation_reason: reason,
canceled_at: new Date()
});
res.json({
success: true,
message: 'Authorization reversed, funds released'
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Background job: Auto-reverse expired authorizations
cron.schedule('0 * * * *', async () => {
const expiredOrders = await db.orders.find({
status: 'authorized',
expires_at: { $lt: new Date() }
});
for (const order of expiredOrders) {
try {
await omise.charges.reverse(order.charge_id);
await db.orders.update({
order_id: order.order_id,
status: 'expired'
});
console.log(`Reversed expired order: ${order.order_id}`);
} catch (error) {
console.error(`Failed to reverse ${order.order_id}:`, error);
}
}
});
app.listen(3000);
Authorization Expiryโ
Authorizations expire if not captured:
| Card Network | Expiry Period |
|---|---|
| Visa | 7 days |
| Mastercard | 7 days |
| Amex | 7 days |
| JCB | 7 days |
Uncaptured authorizations automatically expire after the card network's time limit. Monitor expiry dates and capture before expiration.
// Check if authorization is about to expire
function isExpiringSoon(charge) {
const expiryDate = new Date(charge.expires_at);
const now = new Date();
const daysUntilExpiry = (expiryDate - now) / (1000 * 60 * 60 * 24);
return daysUntilExpiry < 2; // Less than 2 days
}
// Alert if expiring soon
if (isExpiringExpiringSoon(charge)) {
await sendExpiryAlert(order.id, charge.expires_at);
}
Capture Amount Adjustmentsโ
Some scenarios allow capturing a different amount:
Capture Less (Downward Adjustment)โ
// Authorized เธฟ1,000, but final cost is เธฟ800
const capture = await omise.charges.capture('chrg_test_...', {
capture_amount: 80000 // เธฟ800 instead of เธฟ1,000
});
Use cases:
- Final bill lower than estimate
- Customer returned some items before shipping
- Promotional discount applied
- Damage or defect discovered
Capture More (Not Supported)โ
You CANNOT capture more than the authorized amount. If the final cost is higher, you must:
- Create a new charge for the difference, OR
- Void the original authorization and create a new one for the correct amount
Best Practicesโ
1. Monitor Authorization Expiryโ
// Daily cron job
cron.schedule('0 8 * * *', async () => {
// Find authorizations expiring in 2 days
const expiringCharges = await db.orders.find({
status: 'authorized',
expires_at: {
$gte: new Date(),
$lt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000)
}
});
for (const order of expiringCharges) {
// Alert fulfillment team
await sendSlackNotification({
message: `โ ๏ธ Order ${order.order_id} authorization expires in 2 days!`,
urgency: 'high'
});
// Email customer about delay
await sendEmailToCustomer(order.customer_email, {
template: 'order_delay',
order_id: order.order_id
});
}
});
2. Clear Communicationโ
// When authorizing
await sendEmail({
to: customer.email,
subject: 'Order Confirmed - Payment Authorized',
html: `
<h2>Order #${orderId} Confirmed</h2>
<p>We've authorized your payment of เธฟ${amount / 100}.</p>
<p><strong>Important:</strong> Your card will only be charged when your order ships.</p>
<ul>
<li>Estimated ship date: ${estimatedShipDate}</li>
<li>Authorization expires: ${expiresAt}</li>
</ul>
<p>You'll receive a confirmation email when your card is charged.</p>
`
});
3. Automatic Capture on Shipmentโ
// Integrate with shipping system
app.post('/webhooks/shipstation', async (req, res) => {
const shipment = req.body;
if (shipment.event === 'shipment_created') {
const order = await db.orders.findOne({
tracking_number: shipment.tracking_number
});
if (order && order.status === 'authorized') {
// Auto-capture when shipped
await omise.charges.capture(order.charge_id);
await db.orders.update({
order_id: order.order_id,
status: 'captured',
captured_at: new Date()
});
// Notify customer
await sendShippedEmail(order.customer_email, {
tracking: shipment.tracking_number,
amount: order.amount
});
}
}
res.sendStatus(200);
});
4. Handle Partial Fulfillmentโ
async function handlePartialShipment(orderId, shippedItems) {
const order = await db.orders.findOne({ order_id: orderId });
// Calculate amount for shipped items
const shippedAmount = shippedItems.reduce((sum, item) => {
return sum + (item.price * item.quantity);
}, 0);
// Capture only for shipped items
await omise.charges.capture(order.charge_id, {
capture_amount: shippedAmount
});
// Create new authorization for remaining items
const remainingAmount = order.amount - shippedAmount;
if (remainingAmount > 0) {
const newCharge = await omise.charges.create({
amount: remainingAmount,
currency: 'THB',
customer: order.customer_id,
capture: false
});
await db.orders.create({
parent_order_id: orderId,
charge_id: newCharge.id,
status: 'authorized',
amount: remainingAmount
});
}
}
5. Grace Period for Stock Verificationโ
async function processOrder(orderId) {
const order = await db.orders.findOne({ order_id: orderId });
// Check stock
const inStock = await checkInventory(order.items);
if (inStock) {
// Capture immediately if in stock
await omise.charges.capture(order.charge_id);
await fulfillOrder(orderId);
} else {
// Give 24 hours to restock
setTimeout(async () => {
const stillInStock = await checkInventory(order.items);
if (stillInStock) {
await omise.charges.capture(order.charge_id);
await fulfillOrder(orderId);
} else {
// Cancel and refund
await omise.charges.reverse(order.charge_id);
await notifyCustomer(order.customer_email, 'out_of_stock');
}
}, 24 * 60 * 60 * 1000); // 24 hours
}
}
Testingโ
Test Mode Pre-Authorizationโ
Use test cards to verify your pre-authorization implementation:
const omise = require('omise')({
secretKey: 'skey_test_YOUR_SECRET_KEY'
});
// Test authorization
const charge = await omise.charges.create({
amount: 100000,
currency: 'THB',
card: 'tokn_test_5rt6s9vah5lkvi1rh9c', // Test card token
capture: false,
metadata: {
test_scenario: 'pre_authorization'
}
});
console.log('Status:', charge.status); // 'pending'
console.log('Authorized:', charge.authorized); // true
console.log('Capturable:', charge.capturable); // true
Test Scenariosโ
1. Successful Authorization and Captureโ
// Step 1: Authorize
const auth = await omise.charges.create({
amount: 100000,
currency: 'THB',
card: 'tokn_test_4242', // Success card
capture: false
});
console.log('Authorization successful:', auth.status === 'pending');
// Step 2: Capture
const capture = await omise.charges.capture(auth.id);
console.log('Capture successful:', capture.status === 'successful');
console.log('Customer charged:', capture.paid); // true
2. Authorization and Voidโ
// Authorize
const auth = await omise.charges.create({
amount: 100000,
currency: 'THB',
card: 'tokn_test_4242',
capture: false
});
// Cancel authorization
const reversed = await omise.charges.reverse(auth.id);
console.log('Voided:', reversed.status === 'reversed');
3. Partial Captureโ
// Authorize เธฟ1,000
const auth = await omise.charges.create({
amount: 100000,
currency: 'THB',
card: 'tokn_test_4242',
capture: false
});
// Capture only เธฟ800
const partial = await omise.charges.capture(auth.id, {
capture_amount: 80000
});
console.log('Captured amount:', partial.amount); // 80000
4. Expiring Authorizationโ
// Create authorization
const auth = await omise.charges.create({
amount: 100000,
currency: 'THB',
card: 'tokn_test_4242',
capture: false
});
// Check expiry date
console.log('Expires at:', auth.expires_at);
// Simulate expiry (don't capture before expiry)
// In test mode, charges marked as expired after 7 days
Test Cards for Pre-Authorizationโ
| Card Number | Authorization | Capture | Result |
|---|---|---|---|
| 4242 4242 4242 4242 | Success | Success | Full flow works |
| 4000 0000 0000 0002 | Declined | N/A | Authorization fails |
| 4242 4242 4242 4242 | Success | (Not captured) | Auto-expires after 7 days |
Testing via Dashboardโ
- Create authorization in test mode
- Go to Dashboard โ Charges
- Find the pending charge (status:
pending) - Click Actions dropdown:
- "Capture" - Simulates successful capture
- "Reverse" - Simulates void/cancel
Test Webhooksโ
app.post('/webhooks/omise', (req, res) => {
const event = req.body;
switch (event.key) {
case 'charge.create':
// Authorization created
console.log('Auth created:', event.data.capture); // false
break;
case 'charge.capture':
// Authorization captured
console.log('Captured:', event.data.paid); // true
break;
case 'charge.reverse':
// Authorization voided
console.log('Reversed:', event.data.status); // 'reversed'
break;
case 'charge.expire':
// Authorization expired (not captured in time)
console.log('Expired:', event.data.status); // 'expired'
break;
}
res.sendStatus(200);
});
Testing Error Casesโ
// Test: Cannot capture more than authorized
try {
const auth = await omise.charges.create({
amount: 100000,
capture: false
});
// This should fail
await omise.charges.capture(auth.id, {
capture_amount: 150000 // More than authorized
});
} catch (error) {
console.log('Expected error:', error.message);
}
// Test: Cannot capture already captured charge
try {
const auth = await omise.charges.create({
amount: 100000,
capture: false
});
await omise.charges.capture(auth.id);
await omise.charges.capture(auth.id); // Second capture should fail
} catch (error) {
console.log('Expected error:', error.message);
}
// Test: Cannot capture reversed authorization
try {
const auth = await omise.charges.create({
amount: 100000,
capture: false
});
await omise.charges.reverse(auth.id);
await omise.charges.capture(auth.id); // Should fail
} catch (error) {
console.log('Expected error:', error.message);
}
FAQโ
What's the difference between pre-authorization and regular charges?
- Regular charge (auto-capture): Authorizes and captures immediately in one step. Customer is charged right away.
- Pre-authorization (manual capture): Authorizes first (holds funds), captures later (charges customer). Two separate steps.
How long can I hold an authorization?
Authorization holds last 7 days for most cards. After that, they automatically expire and funds are released to the customer.
Can I capture more than the authorized amount?
No. You can only capture up to the authorized amount. To charge more:
- Create a separate charge for the difference, OR
- Void the original auth and create a new one for the full amount
What happens if I don't capture before expiry?
The authorization automatically expires and is reversed. Funds are released back to the customer. You cannot capture after expiration.
Can I partially capture an authorization?
Yes, you can capture less than the authorized amount if the final cost is lower. The remaining amount is automatically released.
Does the customer see the pending charge?
Yes, most banks show "pending" or "authorized" transactions in online banking. The customer won't be charged until you capture.
Can I use pre-auth with 3D Secure?
Yes, 3D Secure works with pre-authorization. The customer authenticates during authorization, then you capture later when ready.
Related Resourcesโ
- Credit Card Payments - Standard charging
- Saved Cards - Customer API
- Refunds - Refunding captured charges
- Testing - Test pre-auth flow
- Webhooks - Charge events