For developers

API documentation

Everything a product needs to report its invoices, payments, refunds and settlements to AgentKuber. Read sections 1 to 5 before writing code; the rest is reference.

API version 2026-11-13 Download as Markdown
Sections
  1. 1. What you are building
  2. 2. What you need from the account
  3. 3. Signing every request
  4. 4. The rules that make this reliable
  5. 5. Endpoints
  6. 6. Events
  7. 7. Locked months
  8. 8. Backfill
  9. 9. If your product does not issue GST documents properly yet
  10. 10. Go-live checklist
  11. 11. Questions that come up
Building with an AI coding assistant?

Give it this page as plain Markdown, which it reads best: https://agentkuber.com/docs.md. Paste your key and secret into your product's own settings, never into a chat.

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

  1. An outbox table and a small sender job in your product (section 4).
  2. Events emitted at the right moments (section 6).
  3. A daily checkpoint (section 6.8).
  4. A backfill of everything since the books began (section 8).
  5. Your product respects locked months (section 7).
  6. A small status panel in your product's admin: connected or not, last event sent, events waiting, events refused (with the portal's reason).
  7. 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 (.env with mode 600, or an encrypted settings row). The owner pastes the secret into your admin screen or .env directly.
  • 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_URL must 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_body is the exact bytes you send. For GET it 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/json and 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

  • date fields (date, bill_date, paid_on, date_from…) are YYYY-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:30 or …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):

  1. Take pending rows with next_attempt_at <= now, oldest first, at most 200.
  2. Send each to POST /api/v1/events (or up to 500 at once to /api/v1/events/batch).
  3. 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 with number_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 new event_id updates 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_paise are required.
  • category is 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. The igst_paise (or CGST/SGST) you send is the tax the company itself owes, and total_paise is what the vendor billed, without that tax.
  • tds.code is one of contract, 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 of total_paise of 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), by captured_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.cancelled for a document reported in that month is refused with locked_period.
  • A document.issued dated in that month is still accepted (the invoice exists, it must be reported) but is flagged late_document and 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:

  1. 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": []}
  1. 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-Signature over "<timestamp>.<body>", plus X-FP-Event and X-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.

  1. Build the outbox and sender first. The backfill is just many outbox rows.
  2. With the test key, queue everything from books_from to today, in this order: invoices and vouchers, credit and debit notes, cancellations, payments, refunds, settlements, expenses, payouts. Use /events/batch (500 at a time).
  3. Send one checkpoint per calendar month. Fix until each says match.
  4. Open GET /reconcile?date_from=…&date_to=… and compare every figure with your own Finance and Tax screen for the same dates.
  5. 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 checkpoint for each month must say match with 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. A credit note for every refund of an invoiced sale, pointing at the original invoice.
  6. Money truth from the gateway, not from your own order rows: payment id, amount, fee, GST on fee, refund ids, settlement id and UTR.
  7. 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.
  8. 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.

  • /ping with 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 says match; /reconcile equals 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.