GST Verification in Odoo
How to verify a partner's GSTIN inside Odoo with a server action or a small module that calls a GST verification API.
Odoo keeps a partner's GSTIN in the vat field on res.partner, and with VAT
validation on it will tell you the number is shaped like a GSTIN. It will not tell you whether the
registration exists, whether it is still Active, or whether the legal name matches the company you are
about to pay. This guide adds that check in Python: a "Verify GSTIN" button, stored results, an automation
rule and a nightly re-check, plus an external-script route for Odoo Online, where custom modules are not
allowed.
What Odoo checks on its own
With the Indian localisation (l10n_in) installed, the partner's vat field is the
GSTIN, and Odoo uses it on invoices and GST reports. If VAT number validation is enabled, Odoo also checks
the number's structure when you save. That catches typos. It cannot catch a well-formed number that was
cancelled last quarter.
Recent releases of the Indian localisation have also added GSTIN status lookups through Odoo's paid in-app services. What those return, and what they cost, varies by version and edition, so check the release notes for yours. The pattern below works on any version where you control the Python code, and stores the evidence in fields you own.
| Question | Odoo format check | Live verification |
|---|---|---|
| Is it 15 characters in a valid pattern? | Yes | Yes |
| Was this GSTIN actually issued? | No | Yes |
| Is it Active, Suspended or Cancelled today? | No | Yes |
| Does the legal name match the partner? | No | Yes |
| Is the partner filing returns? | No | Yes |
Choosing an approach by hosting
Where your Odoo runs decides most of this. Odoo.sh and self-hosted installs let you install your own Python module. Odoo Online does not.
| Approach | Hosting | Effort | Notes |
|---|---|---|---|
| Server action with Python code only | Any | Low | Cannot make the HTTP call: no imports, no requests |
| Automation rule calling a module method | Odoo.sh, on-premise | Low, once the module exists | Verifies on create or when vat changes |
| Custom module: fields, button, cron | Odoo.sh, on-premise | Medium, about a day | The complete solution; stores evidence on the partner |
| External script over the external API | Odoo Online, Odoo.sh, on-premise | Medium | Needs somewhere to run it and plan-level API access |
Whichever you pick, the lookup is one call: GET https://gstinapi.com/api/get-taxpayer-info/
followed by the GSTIN, with your key in an X-API-Key header. The response carries the
registration under taxpayer_data and the recent GSTR filings under filing_data.
The full reference is in the API docs, and the
ERP integration guide covers where the check belongs in
the vendor lifecycle whatever your ERP.
Approach 1: an automation rule or server action
The obvious first attempt is a server action of type "Execute Python Code" that does
import requests and calls the API. It fails. Server action code is evaluated in Odoo's
restricted sandbox, which rejects import statements, and requests is not one of the
names the sandbox provides. Self-hosting does not lift that restriction; what self-hosting and Odoo.sh give
you is the ability to install a module.
What sandboxed code can do is call a public method on the records it was given. So the split is:
the HTTP call lives in a model method (Approach 2 below), and the automation rule is one line. Create a rule
on Contacts, trigger it on creation and on update with vat as the watched field, and give it an
"Execute Code" action:
records._verify_gstin()
The method writes only its own fields, so it does not re-fire a rule that watches vat. It does
run inside the save, so the user waits for one round trip. If your team creates partners through bulk
imports, leave this rule off and let the scheduled action catch up, so an import of 2,000 rows does not
become 2,000 sequential HTTP calls in one transaction.
Approach 2: a small custom module
The module inherits res.partner, adds four fields and exposes a method that does the lookup.
The API key is read from a system parameter, gstin_verify.api_key, which an administrator sets
under Settings, Technical, System Parameters with developer mode on. Nothing secret goes in the code.
Create the key from your account after you sign up.
# gstin_verify/__manifest__.py
{
"name": "GSTIN Verification",
"version": "17.0.1.0.0", # match your series: 16.0 / 17.0 / 18.0
"depends": ["base"],
"data": ["views/res_partner_views.xml", "data/ir_cron.xml"],
"license": "LGPL-3",
}
# gstin_verify/models/res_partner.py
import logging
import re
import requests
from odoo import fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
API_URL = "https://gstinapi.com/api/get-taxpayer-info/%s"
GSTIN_SHAPE = re.compile(r"^[0-9]{2}[0-9A-Z]{13}$")
class ResPartner(models.Model):
_inherit = "res.partner"
gst_check = fields.Selection(
[("verified", "Verified"), ("not_found", "Not found"), ("unchecked", "Could not check")],
string="GSTIN check", readonly=True, copy=False,
)
gst_status = fields.Char("GST status", readonly=True, copy=False)
gst_legal_name = fields.Char("GST legal name", readonly=True, copy=False)
gst_verified_at = fields.Datetime("GSTIN verified at", readonly=True, copy=False)
def _gstin_api_key(self):
return self.env["ir.config_parameter"].sudo().get_param("gstin_verify.api_key")
def _verify_gstin(self):
"""Verify each partner's GSTIN. Never raises on API trouble.
Returns a problem code ('no_key', 'auth', 'credits') or None."""
key = self._gstin_api_key()
if not key:
return "no_key"
for partner in self:
gstin = (partner.vat or "").replace(" ", "").upper()
if not GSTIN_SHAPE.match(gstin):
continue # malformed: let Odoo's validation complain, spend nothing
try:
resp = requests.get(API_URL % gstin, headers={"X-API-Key": key}, timeout=(5, 15))
except requests.RequestException as exc:
_logger.warning("GSTIN %s: lookup failed: %s", gstin, exc)
partner.write({"gst_check": "unchecked"})
continue
if resp.status_code == 401:
return "auth" # bad key: stop, every call will fail
if resp.status_code == 402:
return "credits" # out of credits: stop the batch
if resp.status_code != 200: # 502 = government source unreachable, retry later
_logger.warning("GSTIN %s: HTTP %s", gstin, resp.status_code)
partner.write({"gst_check": "unchecked"})
continue
try:
data = resp.json().get("taxpayer_data") or {}
except ValueError:
partner.write({"gst_check": "unchecked"})
continue
if data.get("status_code") == 1:
partner.write({
"gst_check": "verified",
"gst_status": data.get("status"), # e.g. Active, Cancelled
"gst_legal_name": data.get("name"), # legal name
"gst_verified_at": fields.Datetime.now(),
})
else: # status_code 0: no registration behind this GSTIN
partner.write({
"gst_check": "not_found",
"gst_status": False,
"gst_legal_name": False,
"gst_verified_at": fields.Datetime.now(),
})
return None
def action_verify_gstin(self):
problem = self._verify_gstin()
messages = {
"no_key": "Set the system parameter gstin_verify.api_key first.",
"auth": "The GSTIN API rejected the key (401). Check gstin_verify.api_key.",
"credits": "The GSTIN API account is out of credits (402).",
}
if problem:
raise UserError(messages[problem])
return True
Four details carry most of the weight. The timeout=(5, 15) tuple caps the connect and read
phases separately, so a hung connection cannot hold an Odoo worker. Network errors, 502s and unparseable
bodies are recorded as "Could not check" and the loop moves on, so nothing ever blocks a save. A 401 or 402
stops the batch, because every following call would fail the same way. And a GSTIN with no registration
behind it comes back as HTTP 200 with taxpayer_data.status_code set to 0, so the
code reads that field before it trusts name or status. The full list of responses
is in the GST API error codes guide.
Only the button raises, and only for problems a person can get fixed. Raising a UserError
rolls back the transaction, which is fine for a button click and wrong for an automation rule, which is
why the rule calls _verify_gstin() directly and ignores the return value.
The shape check is deliberately loose. For the full check-digit validation before you spend a call, the GSTIN validation library post has it in plain Python, and the Python package wraps validation and the API call if you would rather not maintain the client. Session reuse and retry patterns are in the Python integration guide.
The view puts the button and results next to the GSTIN on the partner form:
res.partner.form.gstin.verify
res.partner
Re-verifying on a schedule
A GSTIN that was Active at onboarding can be suspended or cancelled months later, and nothing in Odoo will
tell you. A scheduled action (ir.cron) fixes that. Add this method to the same class; it checks
never-verified partners first, then the oldest verifications, 200 a night:
def _cron_reverify_gstin(self, batch=200):
base = [("vat", "!=", False), ("parent_id", "=", False), ("country_id.code", "=", "IN")]
todo = self.search(base + [("gst_verified_at", "=", False)], limit=batch)
if len(todo) < batch:
todo |= self.search(
base + [("gst_verified_at", "!=", False)],
order="gst_verified_at asc", limit=batch - len(todo),
)
problem = todo._verify_gstin()
if problem:
_logger.error("GSTIN re-verification stopped: %s", problem)
GSTIN: re-verify partners
code
model._cron_reverify_gstin()
1
days
True
Size the batch to your supplier count and credit budget. At ₹0.50 a lookup, 200 a night is about ₹3,000 a month, and only lookups that find a registration are charged; see pricing for the lower rate on larger purchases. Pacing and backoff are covered in rate limits and webhooks, and daily vendor GSTIN monitoring covers what to do when a re-check flips a supplier to Cancelled.
Odoo Online: verify from outside
On Odoo Online you cannot install the module, and a code-only server action cannot make the call. What remains is a script running elsewhere, on a small server or a scheduled cloud job, that reads partners through Odoo's external API, verifies them and writes the result back. External API access on Odoo Online depends on your subscription plan, so confirm yours includes it before you build.
import xmlrpc.client
import requests
URL, DB, USER, ODOO_KEY = "https://yourco.odoo.com", "yourco", "[email protected]", "..."
GSTIN_KEY = "..."
uid = xmlrpc.client.ServerProxy(f"{URL}/xmlrpc/2/common").authenticate(DB, USER, ODOO_KEY, {})
odoo = xmlrpc.client.ServerProxy(f"{URL}/xmlrpc/2/object")
partners = odoo.execute_kw(
DB, uid, ODOO_KEY, "res.partner", "search_read",
[[["vat", "!=", False], ["parent_id", "=", False], ["country_id.code", "=", "IN"]]],
{"fields": ["id", "vat"], "limit": 200},
)
for p in partners:
gstin = p["vat"].replace(" ", "").upper()
try:
r = requests.get(f"https://gstinapi.com/api/get-taxpayer-info/{gstin}",
headers={"X-API-Key": GSTIN_KEY}, timeout=(5, 15))
except requests.RequestException:
continue # network trouble: try again next run
if r.status_code in (401, 402):
raise SystemExit(f"Stopping: HTTP {r.status_code}")
if r.status_code != 200:
continue # 502 and friends: try again next run
data = r.json().get("taxpayer_data") or {}
if data.get("status_code") == 1:
note = f"GSTIN verified: {data.get('status')}, legal name {data.get('name')}"
else:
note = "GSTIN not found in the GST register"
odoo.execute_kw(DB, uid, ODOO_KEY, "res.partner", "message_post",
[[p["id"]]], {"body": note})
Posting to the partner's chatter gives you a dated audit trail without custom fields. If you have Odoo Studio, add fields for status, legal name and verified-at and write to those instead, so you can filter and group on them. Newer Odoo releases are moving away from XML-RPC towards a replacement API, so check the external API documentation for your version before you commit to it.
Odoo 16, 17 and 18 differences
The model code is plain ORM and should run unchanged across these versions. The differences are in views, UI names and a few fields, so check these against your version rather than assuming:
- View modifiers. Odoo 17 replaced the
attrsandstatesattributes with direct expressions such asinvisible="not vat". Useattrson 16 and the direct form on 17 and later. The view above uses neither, so it installs on all three. - List views. Odoo 18 renamed the
<tree>view type to<list>. If you add the GSTIN fields to a list view, use the tag your version expects. - Automation naming. Odoo 16 calls them Automated Actions; Odoo 17 renamed them
Automation Rules and reworked the editor. Both need the
base_automationmodule, and the trigger-on-vatsetup is the same idea in each. - Scheduled action fields. Older versions expose extra
ir.cronfields, such as a call count, that newer ones dropped. If the cron XML fails to load, compare it with a core module's cron file in your version. - Manifest version. Prefix the module version with your series (
16.0.x,17.0.x,18.0.x) so Odoo treats it as compatible.
Frequently asked questions
A note on accuracy. GST rules change often. This article reflects our understanding as of 23 September 2026 and is general information, not tax or legal advice. For the authoritative position, check gst.gov.in and cbic-gst.gov.in, or speak to a qualified tax professional about your specific situation.
Check a GSTIN right now, free
Five lookups a day, no account required. Status, filing history, e-invoicing and jurisdiction.
Free GST search toolBuild it into your product
One REST call, JSON back. Get an API key and 20 free verification credits in under a minute.
Start freeMore on developer integration guides
Browse every guide in the GST API blog.