# 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:

```json
{
  "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

```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)

```php
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

```ts
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.

```sql
-- 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:

```json
{
  "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:

```json
{
  "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:

```json
{"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:

```json
{
  "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.

```json
{
  "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:

```json
{
  "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`

```json
{"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`

```json
{"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`

```json
{"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`

```json
{"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.

```json
{"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`

```json
{"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.

```json
{"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:

```json
{"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):

```json
{"books_from": "2026-10-01", "sales": ["2026-10"], "purchases": ["2026-10"], "books": []}
```

2. 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`):

```json
{"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.
