WooCommerce ships with cash on delivery and almost no control over it. You can turn it on, restrict it to certain shipping methods, and decide whether virtual orders qualify. That is the whole of it.
Everything an Indian store actually needs, a value cap, pin-code rules, a handling fee, a prepaid discount, is either a plugin or thirty lines of code. This walks through the code, because it is a small amount of it and because three overlapping plugins that each modify the order total is a worse outcome than a snippet you understand.
Two warnings before the code. Everything here goes in a small site-specific plugin, never in a theme functions file, because a theme change should not remove your payment rules. And test on a staging copy first, because the failure mode of getting this wrong is not a visual bug, it is orders that cannot be placed.
Written for WooCommerce. If you are on Shopify, most of this is an app rather than code, for reasons covered in the Shopify and WooCommerce comparison.
What WooCommerce gives you, and what it does not
Worth being precise about, because a good deal of the advice online recommends a WooCommerce plugin for something that is already a setting.
| What you want | Built in? | How you get it |
|---|---|---|
| Turn COD on or off | Yes | WooCommerce settings, Payments tab |
| Limit COD to certain shipping methods | Yes | A setting on the COD gateway itself |
| Allow or block COD on virtual orders | Yes | A setting on the COD gateway itself |
| Cap COD above an order value | No | A filter, about ten lines |
| Restrict COD by pin code | No | A filter, about fifteen lines |
| Charge a COD handling fee | No | An action, plus a refresh trigger |
| Discount prepaid orders instead | No | The same action, with a negative fee |
| Block repeat offenders by phone | No | Custom, and worth doing properly |
| The pattern | Anything conditional | Is yours to write |
The two settings that are built in are worth using before you write anything. Restricting COD to specific shipping methods is how a lot of stores handle serviceability without any code at all, because if your courier zones are already modelled as shipping methods then the rule already exists. Whether they are modelled that way depends on how your courier integration is wired in.
Enabling COD properly
WooCommerce settings, Payments, then Cash on delivery. Three things there deserve more thought than they usually get, and all three are easier to judge once you know what a payment gateway charges for COD against prepaid.
The title and description the customer reads
These appear at checkout and most stores leave the defaults. If you charge a fee, name it here. If confirmation is required before dispatch, say so here. This is the cheapest place in the whole flow to set expectations.
Enable for shipping methods
Leave empty and COD is offered everywhere. Populate it and COD only appears for the methods you list. If your courier serviceability is already expressed as shipping zones, this alone can do the job of a pin-code plugin.
Accept for virtual orders
Off by default, and it should usually stay off. Cash on delivery of a downloadable product means nobody delivers anything and nobody collects anything.
The gateway id is cod, lowercase. Every snippet below refers to it by that id, and it is the one string you must not mistype, because unsetting a key that does not exist fails silently and looks exactly like a rule that is not working.
Capping COD above an order value
The most requested rule, and the one with the most broken tutorials behind it. The mechanism is a filter on the list of gateways WooCommerce is about to display, from which you remove COD when your condition is met. Set the threshold from your own refusal data rather than a round number.
add_filter( 'woocommerce_available_payment_gateways', 'ecp_cod_value_cap' );
function ecp_cod_value_cap( $gateways ) {
if ( is_admin() && ! wp_doing_ajax() ) {
return $gateways;
}
if ( ! isset( $gateways['cod'] ) || ! WC()->cart ) {
return $gateways;
}
// 'edit' returns a raw float. The default context returns a formatted
// string with the currency symbol in it, which is the bug below.
$total = (float) WC()->cart->get_total( 'edit' );
if ( $total > 5000 ) {
unset( $gateways['cod'] );
}
return $gateways;
} Above five thousand rupees, cash on delivery disappears from the checkout. Set the number from your own refusal data rather than from a round figure that felt about right.
Three details in that snippet are doing real work. The admin guard stops the rule from interfering with orders created in the dashboard, while still allowing it during AJAX requests, which is when the checkout actually refreshes. The isset check means a store where COD is already disabled does not error. And the cart check matters because this filter can run in contexts where there is no cart at all.
The bug in nearly every version of this you will find
Search this problem and you will find the same snippet reproduced across dozens of sites, with one line that quietly does not work the way it looks like it works. It is worth fixing on any WooCommerce store you inherited rather than assuming the rule is live.
// Wrong, and it will appear to work.
$total = WC()->cart->get_total();
if ( $total > 5000 ) {
unset( $gateways['cod'] );
}
// Right.
$total = (float) WC()->cart->get_total( 'edit' ); WC()->cart->get_total() returns a formatted price string by default, markup and currency symbol included. Comparing that against a number makes PHP cast it, and what it casts to depends on what the string starts with.
The reason this survives in so many tutorials is that it often appears to work. With some currency formats the cast produces something close enough to the real total that the rule fires at roughly the right point, and nobody looks again. With others it produces zero, and the cap silently never applies. Passing the edit context returns the raw float and removes the ambiguity entirely.
If you already have a value cap on your store, go and look at which version of this line you have. It takes thirty seconds and it is the single most likely reason a rule you thought was live is not.
The related question is which total you actually want. get_total includes shipping and any fees already applied. If your cap is meant to describe the value of goods rather than the value of the transaction, get_cart_contents_total is the one you want instead. Neither is wrong, but they are different rules and it is worth knowing which one you wrote.
Restricting COD by pin code
The same filter, a different condition. This is the rule to use surgically rather than broadly, because a blanket geographic ban loses good customers along with the problem ones.
add_filter( 'woocommerce_available_payment_gateways', 'ecp_cod_by_pincode' );
function ecp_cod_by_pincode( $gateways ) {
if ( is_admin() && ! wp_doing_ajax() ) {
return $gateways;
}
if ( ! isset( $gateways['cod'] ) || ! WC()->customer ) {
return $gateways;
}
// Shipping postcode, not billing. They are frequently different and it is
// the shipping one the courier has to reach.
$postcode = WC()->customer->get_shipping_postcode();
if ( ! $postcode ) {
return $gateways;
}
$blocked = array( '110001', '400001' );
if ( in_array( $postcode, $blocked, true ) ) {
unset( $gateways['cod'] );
}
return $gateways;
} Two things to note. Shipping postcode rather than billing, because the courier delivers to one of those and not the other. And a short block list rather than a long allow list, because maintaining serviceability for every pin code in India by hand is not a thing anyone finishes.
For anything beyond a handful of pin codes, keep the list somewhere editable rather than in the code, or let the courier integration answer the question. An option in the database, a CSV your operations person can update, or the courier serviceability API if your courier exposes one. The pattern that fails is a hardcoded array that only a developer can change, because the list is exactly the sort of thing that needs changing on a Tuesday afternoon.
The better version of this feeds from real serviceability data rather than a hand-kept list, which means the courier integration has to exist first. That is a separate integration project and it is usually worth doing before the pin-code rules rather than after.
Adding a COD handling fee
A different hook. Fees are added while the cart calculates totals, not while gateways are being listed, and the fee has to know which payment method the customer has currently selected.
add_action( 'woocommerce_cart_calculate_fees', 'ecp_cod_handling_fee' );
function ecp_cod_handling_fee() {
if ( is_admin() && ! wp_doing_ajax() ) {
return;
}
if ( ! WC()->session ) {
return;
}
$chosen = WC()->session->get( 'chosen_payment_method' );
if ( 'cod' !== $chosen ) {
return;
}
// Name, amount, taxable, tax class. Name it something a customer
// understands, because this string appears on the order.
WC()->cart->add_fee( 'Cash on delivery handling', 50, false, '' );
} Fifty rupees on COD orders. The third argument decides whether the fee is taxable, which is a question for your accountant rather than a default to accept, and the string in the first argument is what the customer sees on the order and the invoice.
A percentage rather than a flat amount is the same hook with the amount calculated from the cart subtotal instead of hardcoded. Whichever you choose, show the amount rather than letting the total change unexplained, because an unexplained total at the last step is where abandonment concentrates. How to build an ecommerce website covers the rest of that checkout.
Making the fee appear when the customer switches method
This is the part that separates a working implementation from one that looks fine in testing and misbehaves in production, and it is the second bug that most tutorials ship with. It is also the sort of thing that quietly breaks during a WooCommerce update if nobody re-tests it, which is what a maintenance plan is for.
The fee hook above reads the chosen payment method from the session. But choosing a payment method is a client-side action, and unless something tells the server to recalculate, the totals the customer sees were computed before they made that choice. The result is a fee that appears one interaction late, or does not appear until the page reloads.
On the classic checkout
jQuery( function ( $ ) {
$( document.body ).on( 'change', 'input[name="payment_method"]', function () {
$( document.body ).trigger( 'update_checkout' );
} );
} ); Ten lines that make the difference between a fee that works and one that appears to. Enqueue it on the checkout page only, and check whether your theme or another plugin is already doing it before adding a second copy.
On the block checkout
WooCommerce has been moving stores to the block-based checkout, and behaviour here is different in a way that is mostly good news. The cart calculate fees hook fires for both checkouts, so the PHP above does not change. The block checkout communicates through the Store API and recalculates totals when the payment method changes, which means the jQuery trigger is neither needed nor useful there.
For anything more involved than a fee that depends on the selected method, the block checkout provides its own mechanism for asking the server to update cart data. It is worth knowing that exists before writing something more elaborate, and worth knowing that this specific area has been under-documented enough that WooCommerce has an open request for documentation on it.
Find out which checkout your store is actually running before you write either version of this. Plenty of Indian stores are still on the classic shortcode checkout because a payment plugin or a theme required it, and the two behave differently enough that testing on the wrong one tells you nothing.
The prepaid discount, which usually works better
Same hook, inverted. Instead of charging COD customers more, pay prepaid customers less. Mechanically it is a negative fee applied when the chosen method is anything other than COD.
add_action( 'woocommerce_cart_calculate_fees', 'ecp_prepaid_discount' );
function ecp_prepaid_discount() {
if ( is_admin() && ! wp_doing_ajax() ) {
return;
}
if ( ! WC()->session ) {
return;
}
$chosen = WC()->session->get( 'chosen_payment_method' );
if ( ! $chosen || 'cod' === $chosen ) {
return;
}
$subtotal = WC()->cart->get_subtotal();
$discount = round( $subtotal * 0.03, 2 );
WC()->cart->add_fee( 'Prepaid discount', -$discount, false, '' );
} Three percent off for paying online. Note the negative amount, and note that this fires for every non-COD method, so check the empty case before the comparison or new sessions get a discount they have not chosen.
Why this often outperforms a fee, despite being arithmetically similar: a discount reads as a reward and a fee reads as a penalty, and the same rupee value produces measurably different sentiment. It also avoids the conversation where a customer feels they were charged for choosing an option you offered them.
Do one or the other, not both, and measure for a full month before deciding whether it worked. Running a fee and a discount simultaneously makes the effect of each impossible to isolate, which is the same mistake as changing four things in a week. The payment gateway guide covers why moving volume to prepaid is worth money beyond the refusals it prevents.
When to use a plugin instead
Nothing above argues against plugins in general. It argues against three overlapping ones that each modify the order total, which is a real situation our WooCommerce development work is called in to untangle more often than any other WooCommerce problem.
Two or three fixed rules that rarely change
A value cap, a handling fee, maybe a short pin-code list. Forty lines in one small plugin, no monthly cost, nothing to conflict with, and you can read the whole thing in a minute.
Rules that non-developers must change weekly
If operations need to adjust caps, zones or fees themselves, a plugin with an admin screen is the right answer. Building that screen yourself costs more than the licence.
Phone verification or OTP
Genuinely worth buying. It needs an SMS or WhatsApp provider, rate limiting, retry handling and fraud considerations, and a half-built version of it is worse than none.
Anything that overlaps something you already have
Two plugins both adding fees, or one adding a fee while a snippet caps the gateway, is how a store ends up with totals nobody can explain. One owner per rule, always.
The discipline that matters more than the choice is keeping every COD rule in one place, whether that place is a snippet or a plugin. This is the same argument as the one for plugin restraint generally, which is most of what separates a WooCommerce store that ages well from one that does not. A COD fee also has to reach the invoice correctly, and what that invoice must contain under GST is a stricter question than what the cart displays.
Testing it, and what to measure afterwards
Payment rules fail in ways that are invisible from the dashboard, because the failure is an order that was never placed. Test deliberately.
- A cart just under the cap and one just over it, checking COD appears and disappears.
- Switching between payment methods and watching the total update without a reload.
- A blocked pin code and a permitted one, with shipping and billing addresses that differ.
- A coupon applied on top, since discounts move the total across your cap.
- The whole flow on a mid-range Android phone on mobile data, not on a laptop.
- An order placed in the dashboard, confirming the admin guard did its job.
Then measure. Change one rule at a time and leave it for a full month, because refusal rate is noisy at low volume and two changes in the same week cannot be told apart. Watch total revenue alongside the refusal rate: a falling refusal rate with falling revenue is not a win, it is a store that has stopped taking orders it could have fulfilled.
The number worth watching that nobody watches is COD remittance against delivered orders. Your courier collects the cash and remits it later as a lump sum, and unless an accounting integration matches those remittances back to individual orders, shortfalls from lost parcels and deducted return freight go unnoticed for months. That is a bigger number than any handling fee will ever recover.
We wire COD caps, pin-code rules, part payment and remittance reconciliation into WooCommerce stores, including ones we did not build. Tell us what your refusal rate looks like and we will tell you which rules are worth applying.
Questions we get asked about this
How do I add COD charges in WooCommerce?
Hook woocommerce_cart_calculate_fees, read the chosen payment method from the WooCommerce session, and call add_fee on the cart when it is cod. On the classic checkout you also need a few lines of JavaScript triggering update_checkout when the payment method changes, or the fee appears one interaction late. On the block checkout the Store API handles that recalculation for you.
How do I disable COD above a certain order value?
Filter woocommerce_available_payment_gateways and unset the cod key when the cart total exceeds your threshold. Read the total with the edit context so you get a raw number, because the default returns a formatted currency string and comparing that against a number is the commonest bug in this code.
Why is my COD fee not showing up at checkout?
Almost always because nothing told the server to recalculate after the customer selected a payment method. On the classic checkout, trigger update_checkout on the payment method change event. If you are on the block checkout, that recalculation happens through the Store API, so check instead that your fee hook is running at all and that the session actually holds the chosen method.
Should I charge a COD fee or discount prepaid orders?
Try one, not both, and measure for a month. Mechanically they are the same hook with the sign reversed, but a discount reads as a reward while a fee reads as a penalty, and stores frequently find the discount moves more volume to prepaid with better sentiment. Running both at once makes it impossible to tell which one did anything.
Can I restrict cash on delivery by pin code without a plugin?
Yes, with the same gateway filter, checking the shipping postcode rather than the billing one. Keep the list somewhere an operations person can edit rather than hardcoded, and for anything beyond a handful of codes feed it from real courier serviceability data instead of maintaining it by hand.
Where should this code live?
In a small site-specific plugin, never in a theme functions file. Payment rules should survive a theme change, and they should be findable by whoever picks up maintenance and support next. One plugin holding all your COD rules is also how you avoid two things silently both modifying the order total.