AgentKuber API: connect a product
Version 1 (API version 2026-11-13). For the developer, or the AI coding assistant, who owns the code of a product that sells something: a web app, a store, a SaaS, a marketplace.
Read sections 1 to 5 before writing code. Sections 6 to 9 are reference. Section 10 is the checklist the account's owner ticks before your product goes live. Section 11 answers the questions that come up most.
This page is also available as plain Markdown at https://agentkuber.com/docs.md, which is the easiest form to hand to an AI coding assistant.
"The portal" below is AgentKuber. "The owner" is an owner or administrator of your organisation's AgentKuber account: the person who creates your keys and decides when your product goes live.
1. What you are building
Your organisation has a AgentKuber account. It holds the books of every company in the organisation and is where the owner and the CA do GST, TDS and income-tax work.
Your product keeps doing what it does today: it takes payments and issues its own GST invoices and credit notes. What changes is that your product now reports every finance fact to the portal as it happens:
| When this happens in your product | You send this event |
|---|---|
| A tax invoice, bill of supply, credit note, debit note, receipt voucher or refund voucher is issued | document.issued |
| An issued document is cancelled | document.cancelled |
| A gateway payment is captured (or its fee becomes known later) | payment.captured |
| A refund is processed | payment.refunded |
| The gateway pays out to the bank (a settlement with a UTR) | settlement.processed |
| Your product records a purchase bill of its own (only if it already has an expense register) | expense.recorded |
| Your product pays an outside person (affiliate commission, partner fee, contractor) | payout.recorded |
| Once a day, for yesterday | checkpoint (your own counts, so the portal can prove nothing is missing) |
Why this matters: a GST return is filed per GSTIN, not per product, and several products often bill under one GSTIN. No single product can see the whole return. The portal can, but only if every product reports everything.
Your product stays the source of truth for its own documents. The portal stores a frozen copy of each document exactly as you issued it. It never edits a document and never issues your invoices for you.
What "done" looks like for your product
- An outbox table and a small sender job in your product (section 4).
- Events emitted at the right moments (section 6).
- A daily checkpoint (section 6.8).
- A backfill of everything since the books began (section 8).
- Your product respects locked months (section 7).
- A small status panel in your product's admin: connected or not, last event sent, events waiting, events refused (with the portal's reason).
- If your product does not issue proper GST documents yet, see section 9 first.
2. What you need from the account
Three values per mode. An owner or administrator of the account creates them in AgentKuber under Set-up > Products & API. The key and secret are shown once.
| Setting | Example | Notes |
|---|---|---|
FINANCE_PORTAL_URL |
https://agentkuber.com |
Base URL, no trailing slash. |
FINANCE_PORTAL_KEY |
fpk_test_3f9a… / fpk_live_81c2… |
Public key id. Says which product and which mode. |
FINANCE_PORTAL_SECRET |
fps_… |
Signing secret. Never sent over the wire, never logged, never committed, never pasted into a chat. |
Rules:
- Store them where your product keeps its other secrets (
.envwith mode 600, or an encrypted settings row). The owner pastes the secret into your admin screen or.envdirectly. - You get a test pair first. Test data lives in a sandbox inside the portal and appears in no report. Build and verify everything with the test pair.
- The live pair is installed only when the owner says so (section 10).
- Never hard-code the URL. A missing or empty
FINANCE_PORTAL_URLmust switch the whole integration off cleanly: your product works exactly as before and nothing is queued.
Check your wiring with one call:
GET {FINANCE_PORTAL_URL}/api/v1/ping
It answers with the product, company and GSTIN the key belongs to, the mode, and the date the books begin:
{
"api_version": "2026-11-13", "mode": "test", "livemode": false,
"source": {"code": "lumen", "name": "Lumen", "status": "testing"},
"company": {"legal_name": "Lumen Labs LLP", "pan": "AAKFL1234M", "constitution": "llp", "state_code": "29"},
"gst_registration": {"gstin": "29AAKFL1234M1Z0", "state_code": "29", "filing_frequency": "monthly"},
"books_from": "2026-10-01",
"server_time": "2026-11-13T09:41:07+00:00", "today_ist": "2026-11-13"
}
If gst_registration.gstin is not the GSTIN printed on your invoices, stop and tell the owner: the key is for the wrong product.
3. Signing every request
Every request carries three headers:
| Header | Value |
|---|---|
X-FP-Key |
your key id |
X-FP-Timestamp |
current unix time in seconds |
X-FP-Signature |
lower-case hex of HMAC-SHA256(secret, timestamp + "." + raw_body) |
raw_bodyis the exact bytes you send. ForGETit is empty, so you sign"<timestamp>.".- Serialise the JSON once, sign those bytes, send those same bytes. Do not let an HTTP library re-serialise after signing.
- The portal refuses a timestamp more than 300 seconds from its own clock (
stale_timestamp). Sign each attempt afresh, including retries. Keep your server clock on NTP. - Always
Content-Type: application/jsonand UTF-8. - HTTPS only. Do not disable certificate checks.
Python
import hashlib, hmac, json, os, time, urllib.request, urllib.error
BASE = os.environ["FINANCE_PORTAL_URL"].rstrip("/")
KEY = os.environ["FINANCE_PORTAL_KEY"]
SECRET = os.environ["FINANCE_PORTAL_SECRET"]
def portal(method: str, path: str, payload=None, timeout=20):
"""Returns (http_status, parsed_json). Raises only on network failure."""
body = b"" if payload is None else json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
ts = str(int(time.time()))
sig = hmac.new(SECRET.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
req = urllib.request.Request(BASE + path, data=body if method != "GET" else None, method=method, headers={
"X-FP-Key": KEY, "X-FP-Timestamp": ts, "X-FP-Signature": sig, "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, json.loads(r.read() or b"{}")
except urllib.error.HTTPError as e:
raw = e.read()
try:
return e.code, json.loads(raw or b"{}")
except ValueError:
return e.code, {"error": {"code": "bad_response", "message": raw[:300].decode("utf-8", "replace")}}
PHP (Laravel or plain)
function finance_portal(string $method, string $path, ?array $payload = null): array {
$base = rtrim(env('FINANCE_PORTAL_URL', ''), '/');
$body = $payload === null ? '' : json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$ts = (string) time();
$sig = hash_hmac('sha256', $ts . '.' . $body, env('FINANCE_PORTAL_SECRET'));
$ch = curl_init($base . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => [
'X-FP-Key: ' . env('FINANCE_PORTAL_KEY'), 'X-FP-Timestamp: ' . $ts,
'X-FP-Signature: ' . $sig, 'Content-Type: application/json',
],
]);
if ($method !== 'GET') { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); }
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($raw === false) { throw new RuntimeException('Finance portal unreachable: ' . $err); }
return [$status, json_decode($raw, true) ?? []];
}
PHP trap: amounts must be integers in the JSON. Cast with (int); never send "49900" (a string) or 499.0.
Node / TypeScript
import { createHmac } from "node:crypto";
export async function portal(method: "GET" | "POST", path: string, payload?: unknown) {
const base = (process.env.FINANCE_PORTAL_URL ?? "").replace(/\/$/, "");
const body = payload === undefined ? "" : JSON.stringify(payload);
const ts = String(Math.floor(Date.now() / 1000));
const sig = createHmac("sha256", process.env.FINANCE_PORTAL_SECRET!).update(`${ts}.${body}`).digest("hex");
const res = await fetch(base + path, {
method,
headers: { "X-FP-Key": process.env.FINANCE_PORTAL_KEY!, "X-FP-Timestamp": ts, "X-FP-Signature": sig,
"Content-Type": "application/json" },
body: method === "GET" ? undefined : body,
signal: AbortSignal.timeout(20_000),
});
return { status: res.status, json: await res.json().catch(() => ({})) };
}
Authentication failures (all HTTP 401 unless noted):
error.code |
Meaning | What to do |
|---|---|---|
missing_credentials |
A header is missing | Fix the client |
invalid_key |
Key unknown or revoked | Stop sending; alert the admin |
bad_timestamp / stale_timestamp |
Not unix seconds / clock is off or the request was signed long ago | Sign afresh; fix the clock |
bad_signature |
Bytes signed differ from bytes sent, or wrong secret | Sign the exact body bytes |
source_paused (403) |
The owner paused this product in the portal | Keep queueing; retry hourly |
account_suspended (403) |
The organisation's whole account is suspended | Keep queueing; retry hourly; tell the owner |
rate_limited (429) |
More than 600 requests a minute | Wait for Retry-After; use the batch endpoint |
4. The rules that make this reliable
4.1 Money is integer paise
Every amount is a JSON integer in paise: Rs 499.00 is 49900. No decimals, no strings, no rupees. A float is refused with a message naming the field. All amounts are positive: a credit note of Rs 499 has total_paise: 49900, and the document type carries the sign. Only round_off_paise may be negative.
4.2 Dates are IST
datefields (date,bill_date,paid_on,date_from…) areYYYY-MM-DD, the Indian calendar date printed on the document.- Date-times (
captured_at,settled_at,issued_at…) are ISO 8601 with an offset:2026-11-13T10:22:31+05:30or…Z. A date-time with no offset is read as IST. - If your database stores UTC, convert before you derive the document date. A payment at 00:10 IST is the next day's invoice.
4.3 Every event has a stable id
event_id is a string of up to 200 characters that you choose. It must be the same on every retry and unique within your product. Build it from the thing itself, not from a random number at send time:
| Event | Suggested event_id |
|---|---|
document.issued |
doc:<number> e.g. doc:INV/2627/00517 |
document.cancelled |
doc-cancel:<number> |
payment.captured |
pay:<payment_id> for the first report; pay:<payment_id>:fee when you re-send with the final fee |
payment.refunded |
refund:<refund_id> |
settlement.processed |
settlement:<settlement_id> |
expense.recorded |
expense:<your bill id>:v<n> (bump n when the bill changes) |
payout.recorded |
payout:<your payout id>:v<n> |
checkpoint |
checkpoint:<date_from>:<date_to>:<run number> |
The portal's answer to an event_id it has already accepted is HTTP 200 with "status": "duplicate" and the original result. Treat that as success.
4.4 Use an outbox. Never call the portal inside a customer's request
Write the event into an outbox table in the same database transaction that creates the invoice, refund or settlement record. A separate job sends it. This is what guarantees that no document can exist without its event, and that a slow or unreachable portal can never break a checkout.
-- MySQL (PostgreSQL: use BIGSERIAL, JSONB, TIMESTAMPTZ)
CREATE TABLE finance_outbox (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
livemode TINYINT(1) NOT NULL, -- 1 = queued for the live key, 0 = for the test key
event_id VARCHAR(200) NOT NULL,
type VARCHAR(60) NOT NULL,
payload JSON NOT NULL, -- the full event: {event_id, type, occurred_at, data}
status VARCHAR(12) NOT NULL DEFAULT 'pending', -- pending | sent | refused
attempts INT NOT NULL DEFAULT 0,
next_attempt_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_http INT NULL,
last_error VARCHAR(600) NULL, -- the portal's error.code + message when refused
portal_result JSON NULL, -- the portal's answer when accepted (issues are in here)
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
sent_at DATETIME NULL,
UNIQUE KEY uq_event (livemode, event_id),
KEY ix_due (status, next_attempt_at)
);
The outbox is per mode. The portal keeps test and live apart: an event_id it accepted from your test key has never been seen in live. So the unique key is (livemode, event_id), not event_id alone, and the sender sends only the rows of the mode its current key is for. If the key is event_id alone, the backfill you run after installing the live key finds every row already there and marked sent (to the sandbox), sends nothing, and the live books stay empty while your panel shows green. One product found exactly this on the day it went live.
Sender job (cron every minute, or a queue worker; one instance at a time):
- Take
pendingrows withnext_attempt_at <= now, oldest first, at most 200. - Send each to
POST /api/v1/events(or up to 500 at once to/api/v1/events/batch). - By outcome:
| Portal answer | Mark the row | Then |
|---|---|---|
201 accepted or 200 duplicate |
sent, store the answer |
Done. If issues is not empty, show them in your admin panel |
422 / 409 / 404 rejected (any code except conflict_retry) |
refused, store error.code and error.message |
Do not retry by itself. Show it in the admin panel. After the cause is fixed, set it back to pending with the same event_id |
409 conflict_retry, 429, any 5xx, timeout, network error, 401 stale_timestamp |
stay pending, attempts + 1 |
Retry after 1 min, 5 min, 30 min, 2 h, 6 h, then every 24 h. After 3 days of failure alert the admin, keep trying daily |
401 invalid_key / bad_signature, 403 source_paused / account_suspended / subscription_unpaid |
stay pending |
Stop the run, alert the admin, retry hourly |
Never delete outbox rows; they are your proof of what was sent.
4.5 Order
Send in the order things happened when you can. If you cannot, the portal copes: a credit note that arrives before its invoice, or a refund before its payment, is flagged and then links itself when the other half arrives. Do not hold events back to enforce order.
4.6 Documents are frozen; other things can be re-sent
- A document never changes after
document.issued. Re-sending the same number with the same figures is fine (answered as already recorded). Re-sending it with different figures is refused withnumber_conflict. To change what a customer was billed: cancel (before the month is filed) or issue a credit note, then issue a new number. - Payments, refunds, settlements, expenses and payouts are matched on their own id (
payment_id,refund_id,settlement_id,external_ref). Sending again with a newevent_idupdates the same row. Use this when a card fee becomes known later, or a settlement gets its UTR.
4.7 What is refused and what is only flagged
The portal records a document as it was really issued, even when it is wrong, and raises an issue for a person to deal with. It refuses only what it cannot store meaningfully.
Refused (you get an error, nothing is stored):
error.code |
HTTP | Meaning |
|---|---|---|
invalid_payload |
422 | A field is missing or malformed. error.field names it, e.g. data.lines[0].taxable_paise |
unknown_type |
422 | type is not one of the eight event types |
mode_mismatch |
409 | The event says livemode true but the key is a test key (or the reverse) |
gstin_mismatch |
409 | supplier_gstin is not the GSTIN this key bills under. Wrong key or wrong product |
own_gstin |
409 | Buyer (or vendor) GSTIN equals the supplier's own. One GSTIN cannot supply to itself: do not invoice internal purchases |
before_books |
422 | The document is dated before the company's books begin (books_from in /ping). Do not send earlier documents |
date_in_future |
422 | Document date is after today (IST) |
number_conflict |
409 | This number already exists with different figures |
unknown_document |
404 | document.cancelled for a number never sent. Send document.issued first |
locked_period |
409 | The month is filed and locked. Issue a credit note instead of cancelling |
payment_conflict / settlement_conflict |
409 | That id already belongs to another company of your organisation |
duplicate_bill |
409 | The same vendor bill number is already in the books for that year |
conflict_retry |
409 | Two requests touched one record at once. Send again |
subscription_unpaid |
403 | The account's subscription to the portal is unpaid. Nothing is stored. Keep the event pending and retry hourly: it is accepted as soon as the owner pays |
Flagged (HTTP 201, the event is accepted; issues lists each problem with code, severity, message, what_to_do):
number_too_long (over 16 characters, Rule 46(b)), number_bad_chars, lines_vs_totals, total_mismatch, tax_vs_rate, cgst_sgst_unequal, tax_head_mismatch (IGST charged inside the state, or CGST/SGST outside it), buyer_gstin_invalid (check digit fails), pos_missing (no customer state on a taxed B2C invoice), pos_differs_from_gstin, orig_missing, orig_not_found, credit_exceeds_invoice, late_document (its month was already filed; it is reported in the next open month), hsn_missing, hsn_short, rate_unusual, own_series_clash, payment_amount_mismatch, unregistered_tax, intercompany, refund_without_payment, refund_without_credit_note.
An error-severity issue means a real invoice is wrong. Show issues in your admin panel; most of them point to a bug in how your product builds invoices.
5. Endpoints
Base path /api/v1. All answers are JSON.
| Method and path | Purpose |
|---|---|
GET /ping |
Prove the credentials; learn company, GSTIN, mode, books_from |
POST /events |
One event. 201 accepted, 200 duplicate, 4xx refused |
POST /events/batch |
{"events": [ … ]}, up to 500 and 8 MB. Always 200; read results[], one per event, in order. One bad event does not stop the others |
GET /events?event_id=… |
What happened to an event you sent |
GET /documents?number=… |
A document as the portal holds it, and whether its month is locked |
GET /locks |
Months that are filed and closed (section 7) |
GET /reconcile?date_from=…&date_to=… |
The portal's own counts and totals for your product, each series' first and last number, and any missing numbers |
Limits: 600 requests a minute per key; 500 events a batch; request body 8 MB.
Single-event answer when accepted:
{
"event_id": "doc:INV/2627/00517", "type": "document.issued", "status": "accepted",
"object": "document", "id": 1842, "number": "INV/2627/00517",
"report_period": "2026-11", "late": false, "document_status": "issued",
"issues": []
}
When refused:
{
"event_id": "doc:INV/2627/00517", "type": "document.issued", "status": "rejected",
"error": {"code": "invalid_payload",
"message": "data.lines[0].taxable_paise must be a whole number of paise (Rs 499.00 is 49900), not 422.88.",
"field": "data.lines[0].taxable_paise"}
}
Batch answer:
{"received": 3, "accepted": 2, "duplicate": 0, "rejected": 1,
"results": [ {"event_id": "…", "status": "accepted", …}, {"event_id": "…", "status": "rejected", "error": {…}}, {…} ]}
6. Events
Every event has this envelope:
{
"event_id": "doc:INV/2627/00517",
"type": "document.issued",
"occurred_at": "2026-11-13T10:22:31+05:30",
"data": { }
}
occurred_at is optional. livemode may be included (true/false); if it disagrees with the key's mode the event is refused, which protects the real books from a test system that was given the live key by mistake. Fields marked R are required.
6.1 document.issued
Send for every GST document the moment it gets its number.
{
"event_id": "doc:INV/2627/00517",
"type": "document.issued",
"occurred_at": "2026-11-13T10:22:31+05:30",
"data": {
"doc_type": "tax_invoice",
"number": "INV/2627/00517",
"date": "2026-11-13",
"issued_at": "2026-11-13T10:22:31+05:30",
"supplier_gstin": "29AAKFL1234M1Z0",
"buyer": {
"name": "Rohit Sharma", "gstin": null, "state_code": "07", "country": "IN",
"email": "rohit@example.com", "phone": "+919800000000", "address": null
},
"place_of_supply": "07",
"lines": [
{"description": "Pro plan, 1 month", "hsn_sac": "998315", "qty": 1, "uqc": "OTH",
"unit_price_paise": 42288, "discount_paise": 0,
"taxable_paise": 42288, "gst_rate": 18,
"cgst_paise": 0, "sgst_paise": 0, "igst_paise": 7612, "cess_paise": 0,
"nature": "taxable", "category": "plan"}
],
"totals": {"taxable_paise": 42288, "cgst_paise": 0, "sgst_paise": 0, "igst_paise": 7612,
"cess_paise": 0, "round_off_paise": 0, "total_paise": 49900},
"payment": {"gateway": "razorpay", "payment_id": "pay_QxYz123", "method": "upi", "order_ref": "order_Abc"},
"customer_ref": "user:48213",
"category": "plan",
"internal": false,
"pdf_url": "https://lumen.example/invoices/INV-2627-00517?t=…",
"metadata": {"checkout_session": "cs_901"}
}
}
| Field | Notes | |
|---|---|---|
doc_type |
R | tax_invoice, bill_of_supply, credit_note, debit_note, receipt_voucher (advance received, e.g. a wallet top-up treated as an advance), refund_voucher (advance refunded) |
number |
R | Exactly as printed. At most 40 characters are stored; more than 16, or characters other than letters, digits, - and /, is flagged (Rule 46(b)) |
date |
R | The document date, IST |
supplier_gstin |
R when the product bills under a GSTIN | Must equal the GSTIN in /ping |
buyer.name |
As printed | |
buyer.gstin |
The buyer's GSTIN makes it a B2B document. null for consumers. Must be 15 characters in GSTIN shape; a wrong check digit is flagged |
|
buyer.state_code |
GST state code, 2 digits (06 Haryana, 07 Delhi, 27 Maharashtra…). A state name is accepted too. Send it for every Indian consumer: Circular 242/36/2024 requires the state on online B2C invoices |
|
buyer.country |
ISO 2-letter, default IN |
|
place_of_supply |
State code that decides the tax head. If omitted the portal derives it: buyer GSTIN state, else buyer state, else 96 for a foreign buyer, else the supplier's own state (flagged pos_missing) |
|
export |
Present only for exports: {"type": "WOPAY" or "WPAY", "currency": "USD", "fx_rate": 84.5, "foreign_total": 12.00}. Amounts in the rest of the document stay in INR paise |
|
supply_type |
Send "sez" for a supply to an SEZ unit; otherwise omit |
|
reverse_charge |
true only when the buyer pays the tax under reverse charge. Default false |
|
ecommerce_gstin |
Only when sold through another e-commerce operator | |
lines[] |
R, at least one | One per invoice line |
lines[].taxable_paise |
R | Value before GST, after discount |
lines[].gst_rate |
R | Percent: 18, 5, 0… |
lines[].cgst_paise sgst_paise igst_paise cess_paise |
Tax as printed. Inside the supplier's state: CGST = SGST, IGST 0. Outside: IGST only | |
lines[].hsn_sac |
Digits only, 4 to 8. Missing is flagged | |
lines[].qty uqc |
Default qty 1. For goods send the unit (PCS, NOS…) |
|
lines[].unit_price_paise discount_paise description category |
For the registers | |
lines[].nature |
taxable (default), nil, exempt, non_gst |
|
totals |
As printed. If omitted the portal adds up the lines. total_paise should equal taxable + taxes + round_off_paise |
|
reference |
R for credit_note, debit_note, refund_voucher |
{"number": "<original invoice or voucher>", "date": "YYYY-MM-DD", "reason": "…"} |
advance |
On an invoice that uses up an earlier advance: {"voucher_number": "…", "taxable_paise": …, "cgst_paise": …, "sgst_paise": …, "igst_paise": …} (the part of this invoice already taxed on the receipt voucher) |
|
payment |
{"gateway", "payment_id", "method", "order_ref"}. payment_id is how the portal ties the invoice to the money |
|
customer_ref category |
Your own user id and revenue category | |
internal |
true for a purchase by the business's own owner or staff. It is still a real invoice and is still filed; the flag only lets reports set it apart |
|
pdf_url |
A long-lived signed https:// link to the PDF. Anything that is not an http(s) address is refused |
|
metadata |
Small free-form object, under 8 KB |
Tax arithmetic the portal checks (flags, never corrects): tax on a line within 2 paise of taxable × rate; CGST equals SGST; lines add up to totals; total equals the parts; the tax head matches the place of supply.
A credit note, complete:
{
"event_id": "doc:CN/2627/00009", "type": "document.issued",
"data": {
"doc_type": "credit_note", "number": "CN/2627/00009", "date": "2026-11-14",
"supplier_gstin": "29AAKFL1234M1Z0",
"buyer": {"name": "Rohit Sharma", "state_code": "07"},
"place_of_supply": "07",
"reference": {"number": "INV/2627/00517", "date": "2026-11-13", "reason": "Cancelled within 7 days, refunded"},
"lines": [{"description": "Pro plan, 1 month", "hsn_sac": "998315", "taxable_paise": 42288,
"gst_rate": 18, "igst_paise": 7612}],
"totals": {"taxable_paise": 42288, "igst_paise": 7612, "total_paise": 49900}
}
}
6.2 document.cancelled
{"event_id": "doc-cancel:INV/2627/00042", "type": "document.cancelled",
"data": {"number": "INV/2627/00042", "cancelled_at": "2026-10-30T16:05:00+05:30", "reason": "Fraudulent order"}}
The document keeps its number and is counted as cancelled in the return's document table. If its month is already filed the portal answers locked_period: do not cancel, issue a credit note.
6.3 payment.captured
{"event_id": "pay:pay_QxYz123", "type": "payment.captured",
"data": {
"gateway": "razorpay", "gateway_account": "main",
"payment_id": "pay_QxYz123", "order_ref": "order_Abc",
"amount_paise": 49900, "currency": "INR",
"fee_paise": 1178, "fee_tax_paise": 180,
"method": "upi", "method_detail": "okhdfcbank",
"captured_at": "2026-11-13T10:22:29+05:30",
"customer": {"email": "rohit@example.com", "phone": "+919800000000", "ref": "user:48213"},
"document_number": "INV/2627/00517",
"internal": false
}}
| Field | Notes | |
|---|---|---|
payment_id |
R | The gateway's id. The row is matched on it |
amount_paise |
R | Greater than zero |
captured_at |
R | |
fee_paise |
The gateway's fee including its GST. Razorpay's fee. It may be larger than amount_paise (a Re 1 mandate sign-up carries the gateway's minimum fee): send it as charged, never cut it down |
|
fee_tax_paise |
The GST part of the fee. Razorpay's tax. Must not exceed fee_paise |
|
gateway |
A short lower-case name, kept as you send it. Default razorpay. Use cod for cash on delivery, bank for money that arrived in the bank account directly (NEFT, IMPS, a UPI transfer, a cheque once cleared) and cash for cash in hand. For a payment with no gateway, payment_id is your own stable id for the receipt |
|
document_number |
The invoice (or receipt voucher) this payment is for | |
internal |
true with no document_number when the payer is an account of the business itself, billed under the supplier's own GSTIN (one product of the firm buying from another). No invoice is due for it (see own_gstin), so the portal does not raise payment_without_document. The money is still real: send the payment |
Only captured money. Do not send failed or merely authorised payments. For cards Razorpay may report the fee as null at capture: send the payment anyway with fee 0, then send again with a new event_id (pay:<id>:fee) when you know the fee, usually from the settlement report. The portal builds the month's gateway-fee bill and its GST credit from these fields, so they must be right.
6.4 payment.refunded
{"event_id": "refund:rfnd_Lm45", "type": "payment.refunded",
"data": {"gateway": "razorpay", "refund_id": "rfnd_Lm45", "payment_id": "pay_QxYz123",
"amount_paise": 49900, "status": "processed", "processed_at": "2026-11-14T12:01:00+05:30",
"document_number": "CN/2627/00009", "reason": "Cancelled within 7 days"}}
refund_id, amount_paise and processed_at are required. status is processed (default), pending or failed. document_number is the credit note; a processed refund without one is flagged, because without a credit note the GST on the original invoice stays payable.
6.5 settlement.processed
{"event_id": "settlement:setl_9Kq", "type": "settlement.processed",
"data": {"gateway": "razorpay", "gateway_account": "main", "settlement_id": "setl_9Kq",
"amount_paise": 4872200, "fees_paise": 0, "tax_paise": 0,
"utr": "AXISCN1513597024", "settled_at": "2026-11-15T09:30:00+05:30", "status": "processed",
"payment_ids": ["pay_QxYz123", "pay_QxYz124"], "refund_ids": ["rfnd_Lm45"]}}
settlement_id, amount_paise (net credited to the bank) and settled_at are required. utr is what the CA matches against the bank statement: always send it. payment_ids and refund_ids are the items inside the settlement (from Razorpay's settlement recon API); with them the portal can prove that payments less fees less refunds equals the bank credit. If several products of the organisation share one gateway account, each reports the settlements it sees; the same settlement_id is stored once.
6.6 expense.recorded
Only for products that already keep their own purchase register. New bills are entered in the portal itself. Do not send gateway fees as expenses: the portal derives them from payments.
{"event_id": "expense:412:v1", "type": "expense.recorded",
"data": {
"external_ref": "bill:412",
"vendor": {"name": "Anthropic PBC", "gstin": null, "pan": null, "state_code": null, "country": "US"},
"bill_number": "INV-2026-11-0042", "bill_date": "2026-11-05", "due_date": null,
"category": "software", "description": "Claude API usage, October", "hsn_sac": "998431",
"currency": "USD", "fx_rate": 84.5, "foreign_amount": 100.00,
"taxable_paise": 845000, "cgst_paise": 0, "sgst_paise": 0, "igst_paise": 152100, "cess_paise": 0,
"other_paise": 0, "total_paise": 845000, "gst_rate": 18,
"reverse_charge": true, "itc_eligible": true,
"tds": {"code": null, "rate": null, "amount_paise": 0},
"capital": false, "paid_on": "2026-11-05", "paid_paise": 845000,
"attachment_url": "https://…", "cancelled": false
}}
external_ref(R) is your own stable id for the bill; sending it again updates the same bill (refused once that month's purchases are locked).vendor.name,bill_number,bill_date,taxable_paiseare required.categoryis one of:purchases,shipping,gateway,hosting,software,communication,advertising,professional,contractors,technical,commission,salaries,staff_welfare,rent,utilities,office,repairs,travel,insurance,rates_taxes,bank_charges,interest,partner_pay,other.- Reverse charge (
reverse_charge: true, the default for a foreign vendor): the vendor charged no GST. Theigst_paise(or CGST/SGST) you send is the tax the company itself owes, andtotal_paiseis what the vendor billed, without that tax. tds.codeis one ofcontract,professional,technical,director_fees,commission,rent_building,rent_machinery,interest_other,goods,partner,benefit,ecommerce,dividend,nonresident.
6.7 payout.recorded
{"event_id": "payout:aff-2026-11-118:v1", "type": "payout.recorded",
"data": {"external_ref": "aff-2026-11-118",
"payee": {"name": "Ravi Kumar", "pan": "ABCPK1234F", "ref": "affiliate:77", "type": "individual_huf"},
"nature": "commission", "paid_on": "2026-11-06",
"gross_paise": 2500000,
"tds": {"code": "commission", "rate": 2, "amount_paise": 50000},
"status": "paid"}}
external_ref, payee.name, nature, paid_on, gross_paise are required. nature: commission, professional, contract, technical, rent, partner or your own word. payee.type: individual_huf or other (firm, company). status: accrued (owed, not yet paid), paid, reversed. Send the payout even when no tax was deducted: the portal tracks each payee's yearly total against the TDS thresholds and tells the CA when deduction should have started.
6.8 checkpoint
Once a day, shortly after midnight IST, for yesterday (and again for a whole month on the 1st). You state your own counts; the portal compares them with what it holds.
{"event_id": "checkpoint:2026-11-12:2026-11-12:1", "type": "checkpoint",
"data": {
"date_from": "2026-11-12", "date_to": "2026-11-12",
"documents": {
"tax_invoice": {"count": 41, "total_paise": 1893400, "first": "INV/2627/00476", "last": "INV/2627/00516"},
"credit_note": {"count": 1, "total_paise": 49900, "first": "CN/2627/00008", "last": "CN/2627/00008"}
},
"payments": {"count": 41, "amount_paise": 1893400},
"refunds": {"count": 1, "amount_paise": 49900},
"balances": {"customer_wallet_paise": 1250000}
}}
How to count, so both sides agree:
documents.<type>.count: every number of that type dated in the range, including cancelled ones.documents.<type>.total_paise: sum oftotal_paiseof those documents that are not cancelled.first/last: the lowest and highest number of that type issued in the range (one series). With these the portal lists any number in between that it never received.payments: captured in the range (IST), bycaptured_at.refunds: processed in the range.balances.customer_wallet_paise(optional): unspent customer wallet credit at the end of the range. It is money received in advance; the CA needs it at year end.
Answer:
{"status": "accepted", "object": "checkpoint", "result": "mismatch",
"portal": {"documents": {"tax_invoice": {"count": 40, "cancelled": 0, "total_paise": 1843500, "taxable_paise": 1562288}}, "payments": {…}, "refunds": {…}},
"differences": [{"what": "documents.tax_invoice.count", "product": 41, "portal": 40}],
"missing_numbers": {"tax_invoice": ["INV/2627/00491"]}}
On mismatch: re-queue the missing documents (their event_id is unchanged, so anything the portal already has comes back as a duplicate), then send the checkpoint again with a new run number. A mismatch that stays open is shown to the owner as an error.
7. Locked months
When the CA files GSTR-1 for a month, the portal locks that month's sales for the GSTIN. From then on:
document.cancelledfor a document reported in that month is refused withlocked_period.- A
document.issueddated in that month is still accepted (the invoice exists, it must be reported) but is flaggedlate_documentand reported in the next open month. This should never happen if your outbox is healthy.
Your product must not let anyone cancel, delete or re-date a document in a locked month. Offer "issue a credit note" instead. Two ways to know:
- Poll
GET /api/v1/locks(hourly is plenty, and always just before allowing a cancellation):
{"books_from": "2026-10-01", "sales": ["2026-10"], "purchases": ["2026-10"], "books": []}
- Or give the owner a webhook URL (HTTPS) for your product. The portal then posts to it, signed with a separate webhook secret using the same scheme as section 3 (
X-FP-Timestamp,X-FP-Signatureover"<timestamp>.<body>", plusX-FP-EventandX-FP-Delivery):
{"id": "whk_5b0e…", "type": "period.locked", "created_at": "2026-11-11T12:40:02+00:00",
"source": "lumen", "data": {"scope": "sales", "period": "2026-10", "gstin": "29AAKFL1234M1Z0"}}
Types: period.locked, period.unlocked, ping. Verify the signature and the 300-second window, answer 2xx within 10 seconds, and treat a repeated id as already handled. Failed deliveries are retried after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h. The webhook is optional; /locks always works.
8. Backfill
The portal must hold everything from the day the company's books begin in the portal (books_from in /ping, set by the account's owner). Nothing earlier: anything dated before books_from is refused.
- Build the outbox and sender first. The backfill is just many outbox rows.
- With the test key, queue everything from
books_fromto today, in this order: invoices and vouchers, credit and debit notes, cancellations, payments, refunds, settlements, expenses, payouts. Use/events/batch(500 at a time). - Send one
checkpointper calendar month. Fix until each saysmatch. - Open
GET /reconcile?date_from=…&date_to=…and compare every figure with your own Finance and Tax screen for the same dates. - Show the owner. When the owner installs the live key, run the same backfill again with it. Event ids are per mode, so the live books start empty and every event must really be sent again: check that your outbox queues them as new live rows (section 4.4). Then prove it: the live
checkpointfor each month must saymatchwith the same counts the test one had, not with zeros.
Idempotency makes all of this safe to repeat.
9. If your product does not issue GST documents properly yet
The portal prepares the returns, but it records what your product issued; it does not issue invoices for you. So the product itself must issue correct documents. The minimum, in the order to build it:
- Seller identity in settings, not in code: legal name, GSTIN, PAN, address exactly as on the GST certificate, state code. Ask the owner which company your product bills under; do not assume.
- A document table that is frozen at issue: type, number, date (IST), buyer name / GSTIN / state, place of supply, per-line HSN/SAC, taxable value, rate, CGST, SGST, IGST, totals, the payment id, status (issued or cancelled), original document for notes, and a JSON snapshot of everything printed. Reports and PDFs read this table, never the live order.
- Gap-free numbering per financial year (April to March, IST), under a database lock, at most 16 characters, a prefix that no other product under the same GSTIN uses. A cancelled document keeps its number. Ask the owner for your prefix.
- Place of supply from the buyer: the buyer's GSTIN state, else the state they gave at checkout (collect it: it is mandatory for online B2C sales), else the supplier's state. Inside the supplier's state charge CGST + SGST in equal halves, otherwise IGST.
- A credit note for every refund of an invoiced sale, pointing at the original invoice.
- Money truth from the gateway, not from your own order rows: payment id, amount, fee, GST on fee, refund ids, settlement id and UTR.
- A Finance and Tax screen for the owner: sales register with filters and export, invoices and credit notes with PDF, payments, refunds, settlements, and the portal status panel from section 1.
- Then everything in this guide.
10. Go-live checklist
The owner (or the CA) ticks these in the portal under Set-up > Products & API > your product.
/pingwith the test key shows the right product, company and GSTIN.- Outbox rows are written in the same transaction as each document, payment, refund and settlement (show the code).
- The sender job runs every minute, one instance, with the retry rules of section 4.4.
- A refused event is visible in the product's admin with the portal's message, and can be re-queued.
- Test backfill from
books_from: every month's checkpoint saysmatch;/reconcileequals the product's own Finance and Tax screen to the paisa. - One of each event type the product can produce has been seen in the portal's event log (invoice, credit note, cancellation, payment with fee, refund, settlement with UTR and payment ids, payout or expense if applicable).
- Open issues raised by the test data have been read; each is either fixed in the product or accepted by the owner.
- The product refuses to cancel a document in a locked month (test: the CA locks a month in the sandbox).
- With the integration switched off (empty
FINANCE_PORTAL_URL) the product behaves exactly as before. - The secret is in no log, no repository and no chat.
- Live key installed by the owner; live backfill done; first live daily checkpoint says
match.
11. Questions that come up
Our amounts are stored in rupees as decimals. Convert with exact arithmetic: int(round(Decimal(amount) * 100)), never float * 100.
A payment covers two invoices, or an invoice is paid in parts. Send each payment with the document_number it mainly belongs to; the portal flags the amount difference as information. Tell the owner if this is routine for your product.
A business customer deducted TDS and paid us less than the invoice. Send payment.captured with the amount that actually arrived. The portal notes the difference from the invoice as information. Tell the owner which invoices these are: the TDS is the company's tax credit and is recorded in the portal by the owner or the CA, not by your product.
Our product has its own purchase register. A bill must be entered in one place only: either in your product (then you send expense.recorded) or in the portal by hand, never both, or it is counted twice. Ask the owner which. The gateway's own fees are never sent as bills: the portal builds that bill from the payments.
We issue no invoice for free or zero-value orders. Then send nothing. A zero-value document that exists and has a number should be sent.
Our series resets or changes prefix. Tell the owner first: prefixes are registered per product so two products never collide under one GSTIN.
The portal says late_document. The document's month was already filed. Your outbox was behind. Find out why; the CA has to carry that invoice into the next return.
Can we fetch data from the portal for our own screens? Only what is in section 5. The portal is not a reporting API for products.
Something in this guide does not fit our product. Do not improvise a variant. Tell the owner what does not fit, so it can be raised with AgentKuber support: the contract changes in one place, for everyone.