WeSaveTax Partner Integration
Embed GST invoicing, payment tracking, and compliance into your product in a weekend.
WeSaveTax provides a complete GST billing platform that can be embedded inside any CRM, ERP, or business software via iframe. Partners get full invoicing, GST filing, buyer/item sync, and webhook events — without building any billing infrastructure.
#Getting Started
#Onboarding Flow
When a company completes your onboarding form, call POST /provision from your backend with all their details — business info, bank details, and logo URL. This creates their full WeSaveTax account in one call, no setup wizard needed.
- 1Company completes your onboarding form with business details.
- 2Your backend calls POST /provision with all their info — business name, GSTIN, address, bank details, logo URL.
- 3WeSaveTax creates a fully configured billing account with billing profile populated.
- 4Embed the iframe for that company — the setup wizard opens pre-filled with what you sent, and one confirmation finishes onboarding.
Example — provision a company with full details
{
"partnerTenantId": "company_123",
"partnerUserEmail": "owner@abctraders.com",
"businessName": "ABC Traders",
"legalName": "ABC Traders Pvt Ltd",
"gstin": "27AAACR5055K1Z5",
"state": "Maharashtra",
"address": {
"street": "123 MG Road",
"city": "Mumbai",
"pincode": "400001"
},
"bankDetails": {
"bank": "HDFC Bank",
"accountNo": "12345678901234",
"ifsc": "HDFC0001234",
"branch": "Andheri West"
},
"logoUrl": "https://res.cloudinary.com/your-storage/logo.png"
}logoUrl. WeSaveTax stores the reference. The legalName field is stored on the billing profile — used on invoice headers for companies where the trading name differs from their registered legal name.#Credentials
| Credential | Format | Used For |
|---|---|---|
API Key | txe_partner_{id}_{hex} | Server-to-server API calls. Send as Authorization: Bearer header. |
Embed Secret | 64 hex chars | Sign iframe JWT tokens with HS256. |
Webhook Secret | 32 hex chars | Verify HMAC-SHA256 webhook signatures. |
You receive two sets: live (partner id {id}, API key txe_partner_{id}_{hex}) and sandbox (partner id {id}_sandbox, API key txe_partner_{id}_sandbox_{hex}). The partnerId you put in the embed JWT decides which set is used — sign it with that set's Embed Secret.
#Embed Integration
#How It Works
- 1Your backend generates a signed JWT (30 min max expiry, unique jti per token) using Embed Secret.
- 2Your frontend renders an iframe: https://staging.wesavetax.com/embed?token={jwt} (production: https://www.wesavetax.com/embed?token={jwt}).
- 3WeSaveTax verifies the JWT, authenticates the user, loads the portal.
- 4User interacts with full WeSaveTax portal inside your app.
- 5Events fire back via window.postMessage.
#JWT Token Format
import jwt from "jsonwebtoken";
import { randomUUID } from "crypto";
const token = jwt.sign(
{
// Required
partnerId: "your_partner_id",
partnerTenantId: "company_123", // YOUR id for the end customer's business — one per company
partnerUserEmail: "user@company.com",
// Required — MUST be unique per token. WeSaveTax rejects any jti
// it has already seen within the token's lifetime (single-use).
jti: randomUUID(),
// Optional: pre-fill company info for setup wizard
companyName: "ABC Traders",
companyGstin: "27AAACR5055K1Z5",
companyState: "Maharashtra",
companyAddress: {
street: "123 MG Road",
city: "Mumbai",
pincode: "400001"
},
// Optional: for deal deep link context
context: "deal",
entityId: "prefill_token_from_invoices_prefill_api",
},
process.env.WESAVETAX_EMBED_SECRET, // Example only — replace with your actual secret from the partner admin dashboard
{ expiresIn: "30m", algorithm: "HS256" } // Max 30 min from iat — WeSaveTax rejects older tokens
);
const embedUrl = `https://staging.wesavetax.com/embed?token=${token}`;#Embed Contexts
| context value | entityId required? | What opens in the embed |
|---|---|---|
billing (default) | No | Full WeSaveTax dashboard |
deal | Yes — prefillToken from POST /invoices/prefill | Invoice builder pre-filled with deal buyer + line items |
contact | Yes — taxEasyyBuyerId | Buyer detail and transaction history |
#Setup Wizard
The first time a company connects, WeSaveTax shows a 4-step setup wizard automatically:
- → Step 1: Confirm business details (pre-filled from JWT payload)
- → Step 2: Add bank details
- → Step 3: Upload logo (optional)
- → Step 4: Set the invoice number prefix
After completion the full portal loads. Never shown again for that company.
companyName, companyGstin, companyState, and companyAddress in the JWT payload to skip manual entry in Step 1.#Session Expiry & Refresh
When the embed is opened with an expired token (older than 30 minutes), WESAVETAX_SESSION_EXPIRED fires. Your app should:
window.addEventListener("message", async (event) => {
if (event.data.type !== "WESAVETAX_SESSION_EXPIRED") return;
// Fetch a fresh token from your backend
const { token } = await fetch("/api/wesavetax/embed-token").then(r => r.json());
// Reload the iframe with the new token
const iframe = document.getElementById("wesavetax-embed");
iframe.src = `https://staging.wesavetax.com/embed?token=${token}`;
});#REST API Reference
All requests: Content-Type: application/json
#Field names: reading vs writing
Item reads (GET /items, GET /items/:id) use different field names from the write endpoints (items/upsert, items/sync, invoices). Send the write name. The read name is also accepted as an alias on writes, so an item you just read can be sent straight back; if you send both, the write name is used.
| Meaning | Write name (preferred) | Read name (also accepted) | Where |
|---|---|---|---|
| Item or line name | title | name | items/upsert, items/sync |
| Line description | description | name | invoices (lineItems) |
| HSN / SAC code | hsn | hsnCode | items/upsert, items/sync, invoices (lineItems) |
| WeSaveTax item id | savedItemId | taxEasyyItemId | invoices (lineItems) |
medicineId, or a misspelt hsn) is dropped and the response lists it in warnings, e.g. "Line 1: unrecognised field "medicineId" ignored.". Check warnings after your first call to each endpoint. This applies to buyers/upsert, buyers/sync, items/upsert, items/sync and invoices. Buyer fields use the same names for reading and writing.#GET/verify
Confirm your API key is valid. Use this to test credentials and check connectivity.
curl https://staging.wesavetax.com/api/connect/v1/verify \
-H "Authorization: Bearer txe_partner_yourname_sandbox_abc123"Response
{
"valid": true,
"partnerId": "your_partner_id",
"mode": "sandbox"
}#POST/provision
Create a WeSaveTax account for a company in your platform. Call this when a company first connects, OR let the embed setup wizard handle it automatically.
Request body
{
"partnerTenantId": "company_123",
"partnerUserEmail": "owner@company.com",
"businessName": "ABC Traders",
"gstin": "27AAACR5055K1Z5",
"legalName": "ABC Traders Pvt Ltd",
"state": "Maharashtra",
"address": {
"street": "123 MG Road",
"city": "Mumbai",
"pincode": "400001"
},
"bankDetails": {
"bank": "HDFC Bank",
"accountNo": "12345678901234",
"ifsc": "HDFC0001234",
"branch": "Andheri West"
},
"logoUrl": "https://res.cloudinary.com/your-logo.png"
}partnerTenantId, partnerUserEmail, and businessName are optional.Responses
// New account created (201)
{ "success": true, "isNew": true, "alreadyExists": false, "clientId": "...", "billingProfileId": "..." }
// partnerTenantId already provisioned (200) — nothing is updated
{ "success": true, "isNew": false, "alreadyExists": true, "clientId": "...", "billingProfileId": "..." }partnerTenantId returns the existing account and does not change any details.Refusals
| Status | code | Meaning |
|---|---|---|
| 409 | EMAIL_EXISTS_NEEDS_CONSENT | The email already has its own WeSaveTax account |
| 409 | EMAIL_BELONGS_TO_OTHER_PARTNER | The email belongs to another partner's company |
| 409 | EMAIL_EXISTS_TENANT_MISMATCH | The email is already used by a different partnerTenantId of yours |
| 409 | GSTIN_ALREADY_PROVISIONED | The GSTIN already has a WeSaveTax account |
| 403 | PARTNER_CLIENT_LIMIT_REACHED | Your live company limit is reached — contact WeSaveTax |
#GET/billing-profile
Read a company's billing profile — business details, bank info, and logo.
Query params
| Param | Required | Description |
|---|---|---|
partnerTenantId | Yes | Your own id for the end customer's business — one per company |
Response
{
"businessName": "ABC Traders",
"legalName": "ABC Traders Pvt Ltd",
"gstin": "27AAACR5055K1Z5",
"state": "Maharashtra",
"address": "123 MG Road",
"city": "Mumbai",
"pincode": "400001",
"country": "IN",
"bankDetails": {
"bank": "HDFC Bank",
"accountNo": "...",
"ifsc": "HDFC0001234",
"branch": "Andheri West"
},
"logo": { "url": "https://..." }
}#GET/buyers
List all buyers/customers for a company. Use this to show existing WeSaveTax buyers in your UI.
Query params
| Param | Required | Description |
|---|---|---|
partnerTenantId | Yes | Your own id for the end customer's business — one per company |
page | No | Page number (default: 1) |
limit | No | Results per page (default: 50, max: 100) |
search | No | Search by buyer name or GSTIN |
Response
{
"data": [
{
"taxEasyyBuyerId": "64f1a2b3c4d5e6f7a8b9c0d1",
"partnerContactId": "contact_456",
"legalName": "Reliance Industries Ltd",
"gstin": "27AAACR5055K1Z5",
"gstRegistrationType": "regular",
"billingAddress": {
"street": "3rd Floor, Maker Chambers IV",
"city": "Mumbai",
"state": "Maharashtra",
"pincode": "400021"
},
"createdAt": "2026-06-22T10:30:00.000Z"
}
],
"pagination": { "page": 1, "limit": 50, "total": 45, "totalPages": 1 }
}gstRegistrationType values: regular · composition · unregistered · consumer · sez · government (default: unregistered)#GET/buyers/:id
Fetch a single buyer by their taxEasyyBuyerId.
Query params
| Param | Required | Description |
|---|---|---|
partnerTenantId | Yes | Your own id for the end customer's business — one per company |
Response
Single buyer object — same shape as a list item.
#POST/buyers/upsert
Sync a contact from your CRM as a buyer in WeSaveTax. Call this before /invoices/prefill so the buyer exists.
Request body
{
"partnerTenantId": "company_123",
"partnerContactId": "contact_456",
"legalName": "Reliance Industries Ltd",
"gstin": "27AAACR5055K1Z5",
"gstRegistrationType": "regular",
"billingAddress": {
"street": "3rd Floor, Maker Chambers IV",
"city": "Mumbai",
"state": "Maharashtra",
"pincode": "400021"
}
}Response
{ "taxEasyyBuyerId": "64f1a2b3c4d5e6f7a8b9c0d1", "isNew": true }| Field | Required | Notes |
|---|---|---|
partnerContactId | Yes | Your id for the contact. It is how we match this buyer on later calls |
legalName | Yes | Shown as the buyer's name in WeSaveTax (both the B2B and B2C lists) |
gstin | No | With a GSTIN the buyer is B2B; without one it is B2C unless you send buyerType |
gstRegistrationType | No | regular, composition, unregistered, consumer, sez or government |
buyerType | No | B2B or B2C. An update never changes the type unless you send this |
pan, email, phone | No | Optional contact and tax details |
billingAddress | No | street, city, state, stateCode, pincode, country |
gstStateCode | No | Two-digit state code. Defaults to the first two digits of the GSTIN, then billingAddress.stateCode |
partnerContactId; if there is none and you sent a GSTIN, by GSTIN. A GSTIN match on a buyer the company added by hand in WeSaveTax updates that buyer with the fields you sent and links it to your partnerContactId. Otherwise a new buyer is created. Partner buyers appear in the company's normal Buyers list.taxEasyyBuyerId in your CRM — use it to avoid re-syncing the same contact repeatedly. Fields not listed above (for example tradeName or companyName) are ignored and returned as "warnings": ["Unrecognised field "companyName" ignored."].#POST/buyers/sync
Bulk upsert up to 200 buyers in one call. Use on first connect to sync all your contacts.
Request body
{
"partnerTenantId": "company_123",
"buyers": [
{
"partnerContactId": "contact_001",
"legalName": "Tata Consultancy Services",
"gstin": "27AAACR5055K1Z5",
"gstRegistrationType": "regular"
},
{
"partnerContactId": "contact_002",
"legalName": "Walk-in Customer",
"gstRegistrationType": "consumer"
}
]
}Response
{ "synced": 2, "created": 1, "updated": 1, "failed": 0, "errors": [] }Same fields and matching rules as /buyers/upsert. When a buyer carries a field we don't recognise, the response adds "warnings": ["Buyer 1: unrecognised field "companyName" ignored."] (the number is the buyer's position in your list).
#GET/items
List all items/products for a company, scoped to tenant. Use this to show existing WeSaveTax items in your UI — for example, when building a prescription or selecting products.
Query params
| Param | Required | Description |
|---|---|---|
partnerTenantId | Yes | Your own id for the end customer's business — one per company |
page | No | Page number (default: 1) |
limit | No | Results per page (default: 50, max: 100) |
search | No | Search by item name |
Response
{
"data": [
{
"taxEasyyItemId": "64f1a2b3c4d5e6f7a8b9c0d2",
"partnerItemId": "item_789",
"name": "Website Design",
"rate": 50000,
"unit": "NOS",
"hsnCode": "998314",
"gstRate": 18,
"imageUrl": "https://res.cloudinary.com/wesavetax/image/upload/v1/wesavetax/item-images/abc123.jpg",
"barcode": null,
"trackInventory": false,
"currentStock": null,
"createdAt": "2026-06-22T10:30:00.000Z"
}
],
"pagination": { "page": 1, "limit": 50, "total": 45, "totalPages": 1 }
}name (mapped from internal title) and hsnCode (mapped from internal hsn). Use these exact field names when reading item data. Writes (items/upsert, items/sync, invoices) take title/description, hsn and savedItemId, but also accept the read names name, hsnCode and taxEasyyItemId as aliases, so an item you read can be sent straight back. When both are sent, the write name wins. A field we don't recognise is ignored and listed in the response warnings. The imageUrl field is a Cloudinary CDN URL when the merchant has added a photo to the item; otherwise null. This field is read-only via the partner API — images are uploaded through the WeSaveTax portal, not via API in this version.currentStock is the sellable stock in the item's unit, or null when trackInventory is false (services). The list includes items the company created in WeSaveTax itself — those have partnerItemId: null; link them on invoices by savedItemId.#GET/items/:id
Fetch a single item by its taxEasyyItemId.
Query params
| Param | Required | Description |
|---|---|---|
partnerTenantId | Yes | Your own id for the end customer's business — one per company |
Response
Single item object — same shape as a list item (includes imageUrl).
#POST/items/upsert
Create or update a product or service from your catalogue, matched on partnerItemId.
Request body
{
"partnerTenantId": "company_123",
"partnerItemId": "item_789",
"title": "Paracetamol 500mg (strip of 10)",
"rate": 25,
"unit": "PAC",
"hsn": "3004",
"gstRate": 5
}| Field | Required | Notes |
|---|---|---|
partnerItemId | Yes | Your id for the item — also how invoice lines refer to it |
title | Yes | Item name (max 200). Must be unique within the company |
rate | Yes | Selling price per unit |
hsn | No | HSN (goods) or SAC (services) code — find it with GET /hsn. A SAC code (starting 99) makes the item a service |
gstRate | No | 0, 0.25, 3, 5, 18 or 40 |
unit | No | An official GST unit code (default NOS) — see below |
description | No | Max 1000 |
Response
{ "taxEasyyItemId": "64f1a2b3c4d5e6f7a8b9c0d2", "isNew": true }title and hsn. name and hsnCode (the names GET /items returns) are accepted as aliases. Any other unknown field is ignored and listed in a warnings array in the response.unit and the goods/service type (from hsn) define how stock is counted, so later upserts don't change them — the company edits them in WeSaveTax. Goods are stock-tracked and start at 0 stock; the company adds opening stock in WeSaveTax. Services are never stock-tracked.Allowed units
Official GST unit codes (GSTR-1 reports them as-is): BAG BAL BDL BKL BOU BOX BTL BUN CAN CBM CCM CMS CTN DOZ DRM GGK GMS GRS GYD KGS KLR KME LTR MLT MTR MTS NOS OTH PAC PCS PRS QTL ROL SET SQF SQM SQY TBS TGM THD TON TUB UGS UNT YDS. Case doesn't matter. The unit names used inside the WeSaveTax app are accepted too and converted (for example LTRS becomes LTR, BOXES becomes BOX, TABLETS becomes TBS); item reads always return the GST code. Units the app has but GST doesn't (HRS DAYS MONTHS YEARS STRIPS SHEETS) are accepted and stored as sent; GSTR-1 reports them as-is, so prefer a GST code where one fits. Anything else returns 400 INVALID_UNIT with the list in allowedUnits.
#POST/items/sync
Bulk upsert up to 500 items in one call.
Request body
{
"partnerTenantId": "company_123",
"items": [
{ "partnerItemId": "item_001", "title": "Paracetamol 500mg", "rate": 25, "unit": "PAC", "hsn": "3004", "gstRate": 5 },
{ "partnerItemId": "item_002", "title": "IT Consulting", "rate": 500, "hsn": "998313", "gstRate": 18 }
]
}Response
{ "synced": 2, "created": 2, "updated": 0, "failed": 0, "errors": [] }Same fields and rules as /items/upsert, including the name and hsnCode aliases; unknown fields come back in a warnings array ("Item 2: unrecognised field ..."). An item that fails (e.g. an invalid unit) is reported in errors: [{ partnerItemId, error }] without stopping the rest.
#GET/hsn
Search HSN (goods) and SAC (services) codes with their default GST rate — use it to pick hsn and gstRate before creating an item. No partnerTenantId needed.
| Param | Required | Description |
|---|---|---|
q | No | Code or description text, e.g. 3004 or paracetamol |
limit | No | Results (default: 10, max: 20) |
{
"data": [
{ "hsn": "3004", "type": "HSN", "description": "Medicaments in measured doses", "gstRate": 5 }
]
}gstRate is the usual rate for the code; some codes carry more than one rate, so confirm with the company when it matters. type: "SAC" means a service.#GET/invoices
List invoices for a company.
Query params
| Param | Required | Description |
|---|---|---|
partnerTenantId | Yes | Your own id for the end customer's business — one per company |
page | No | Page number (default: 1) |
limit | No | Results per page (default: 50, max: 100) |
status | No | Filter by status: draft, sent, partially_paid, paid |
partnerDealId | No | Filter by your deal ID |
includeTest | No | Include sandbox invoices (default: false) |
Response
{
"data": [
{
"taxEasyyInvoiceId": "64f1a2b3c4d5e6f7a8b9c0d3",
"invoiceNumber": "INV-2026-00001",
"invoiceDate": "2026-06-22T00:00:00.000Z",
"buyerName": "Reliance Industries Ltd",
"grandTotal": 118000,
"amountPaid": 0,
"amountDue": 118000,
"paymentStatus": "unpaid",
"status": "sent",
"partnerDealId": "deal_789",
"partnerContactId": "contact_456"
}
],
"pagination": { "page": 1, "limit": 50, "total": 12, "totalPages": 1 }
}includeTest=true to include them.#GET/invoices/:id
Fetch full invoice detail including line items, tax breakdown, buyer, and a PDF download URL. Returns 404 if the invoice does not belong to the authenticated partner's tenant — a partner cannot access invoices belonging to another tenant or another partner.
Query params
| Param | Required | Description |
|---|---|---|
partnerTenantId | Yes | Your own id for the end customer's business — one per company |
Response
{
"taxEasyyInvoiceId": "64f1a2b3c4d5e6f7a8b9c0d3",
"invoiceNumber": "INV-2026-00001",
"invoiceDate": "2026-06-01T00:00:00.000Z",
"dueDate": "2026-06-30T00:00:00.000Z",
"status": "sent", // invoice workflow status
"paymentStatus": "unpaid", // unpaid | partial | paid
"amountPaid": 0,
"amountDue": 59000,
"grandTotal": 59000, // top-level alias (= taxBreakdown.grandTotal)
"buyerName": "Acme Corp", // top-level alias (= buyer.legalName)
"pdfUrl": "https://www.wesavetax.com/api/connect/v1/invoices/64f.../pdf?partnerTenantId=company_123",
"currency": "INR",
"supplyType": "domestic",
"isInterState": false,
"buyer": {
"legalName": "Acme Corp",
"gstin": null,
"address": "123 MG Road",
"city": "Mumbai",
"state": "Maharashtra",
"stateCode": "27",
"pincode": "400001",
"country": null,
"email": null,
"phone": null
},
"lineItems": [
{
"name": "Web Design Services", // API alias (= description in DB)
"description": "Web Design Services",
"hsn": null,
"quantity": 1,
"unit": "NOS",
"rate": 50000,
"gstRate": 18,
"cgst": 4500,
"sgst": 4500,
"igst": 0,
"amount": 50000
}
],
"taxBreakdown": {
"subtotal": 50000,
"taxableAmount": 50000,
"totalCgst": 4500,
"totalSgst": 4500,
"totalIgst": 0,
"totalGst": 9000,
"discountAmount": 0,
"grandTotal": 59000,
"tcs": null
},
"partnerDealId": "deal_789",
"partnerContactId": "contact_456"
}paymentStatus values: unpaid · partial · paid.pdfUrl points to the partner-authenticated PDF endpoint — call it with the same Bearer key (no extra auth needed). lineItems[].name is an alias for description for forward compatibility.Error responses
| Status | Meaning |
|---|---|
| 401 | Missing or invalid API key |
| 400 | partnerTenantId query param missing |
| 404 | Invoice not found, or belongs to a different partner/tenant |
| 429 | Rate limit exceeded |
#GET/invoices/:id/pdf
Download an invoice as a PDF file. Authenticates with the same Bearer key as all other endpoints. Returns 404 if the invoice does not belong to the authenticated partner's tenant (same isolation guarantee as GET /invoices/:id).
Query params
| Param | Required | Description |
|---|---|---|
partnerTenantId | Yes | Your own id for the end customer's business — one per company |
Response
Binary PDF stream. Content-Type: application/pdf. Content-Disposition: attachment; filename="INV-2026-00001.pdf".
# Download the PDF for an invoice
curl https://www.wesavetax.com/api/connect/v1/invoices/64f1a2b3.../pdf?partnerTenantId=company_123 \
-H "Authorization: Bearer txe_partner_dxyra_abc123" \
-o invoice.pdfUsing pdfUrl from GET /invoices/:id
The GET /invoices/:id response includes a ready-to-use pdfUrl field. Call it directly with the same Bearer key — no URL construction needed.
// Fetch invoice details
const inv = await fetch(`/api/connect/v1/invoices/${invoiceId}?partnerTenantId=${tenantId}`, {
headers: { Authorization: `Bearer ${apiKey}` }
}).then(r => r.json());
// Download PDF using the pdfUrl from the response
const pdf = await fetch(inv.pdfUrl, {
headers: { Authorization: `Bearer ${apiKey}` }
});
const blob = await pdf.blob();Error responses
| Status | Meaning |
|---|---|
| 401 | Missing or invalid API key |
| 400 | partnerTenantId query param missing |
| 404 | Invoice not found, or belongs to a different partner/tenant |
| 429 | Rate limit exceeded |
| 500 | PDF generation failed |
#POST/invoices (Headless Invoice Creation)
Create an invoice programmatically without opening the embed. Use this to auto-generate invoices from your own workflows — for example, from a completed prescription or deal.
Specifying the buyer
| Method | Field | When to use |
|---|---|---|
| By WeSaveTax buyer ID | buyerId | Use taxEasyyBuyerId from a previous /buyers/upsert |
| By your contact ID | partnerContactId | If the contact was already synced |
| Inline buyer object | buyer: { ... } | One-off buyer, no prior sync needed |
Request body
{
"partnerTenantId": "company_123",
"buyerId": "64f1a2b3...", // OR partnerContactId OR buyer{}
"partnerDealId": "deal_789", // optional — links invoice to your deal
"partnerContactId": "contact_456", // optional
"invoiceType": "B2B", // optional — auto-derived if omitted
"status": "sent", // optional — "sent" (default, issued) or "draft"
"lineItems": [
{
"partnerItemId": "item_789", // links the item — stock moves; unit/hsn/gstRate come from it
"description": "Paracetamol 500mg (strip of 10)",
"quantity": 2,
"rate": 25
},
{
"description": "Home delivery", // free-text line — no item, no stock
"quantity": 1,
"rate": 50,
"gstRate": 18,
"hsn": "996812"
}
],
"dueDate": "2026-07-31" // optional
}Linking lines to items (stock)
Stock moves only for lines linked to an item. Link with partnerItemId (your id from /items/upsert) or savedItemId (taxEasyyItemId from GET /items — needed for items the company created in WeSaveTax). A linked line takes the item's unit, hsn and gstRate when you leave them out — if you send unit, it must be the item's exact unit. An id that matches no item returns 400 ITEM_NOT_FOUND.
A draft gets no invoice number and moves no stock until it is issued.
Response (201)
{
"taxEasyyInvoiceId": "64f1a2b3c4d5e6f7a8b9c0d3",
"invoiceNumber": "INV-2026-00001",
"grandTotal": 59000,
"status": "sent",
"paymentStatus": "unpaid",
"invoiceDate": "2026-06-22T10:34:32.711Z",
"partnerDealId": "deal_789",
"warnings": []
}warnings — e.g. a goods line that isn't linked to an item (stock not tracked), stock going negative, or a line field we didn't recognise.hsn on the line (hsnCode is accepted as an alias), or link the line to an item with partnerItemId or savedItemId (taxEasyyItemId is accepted as an alias) and it is copied from the item when you leave it out. A line with neither has no HSN/SAC. Your own item id (for example medicineId) is not recognised and links nothing: send partnerItemId instead. The line's unit may be a GST code or the matching app unit name; for a linked line it should match the item's unit.Error responses
| Status | Error | Meaning |
|---|---|---|
| 400 | Validation failed | Body doesn't match the schema — see details |
| 400 | BUYER_REQUIRED | No buyer specified in request body |
| 400 | BUYER_NOT_FOUND | buyerId or partnerContactId matches no buyer |
| 400 | ITEM_NOT_FOUND | partnerItemId or savedItemId matches no item — see partnerItemIds / savedItemIds |
| 404 | Tenant not found | Invalid or unknown partnerTenantId |
| 403 | FEATURE_LOCKED | The company's plan or business type doesn't include this (e.g. export supply types) |
| 409 | TRANSACTION_CONFLICT | Temporary conflict — safe to retry (retryable: true) |
| 422 | INSUFFICIENT_STOCK | Not enough stock for a linked item, and the company doesn't allow negative stock |
| 422 | UNIT_NOT_CONFIGURED | The line's unit isn't the item's unit |
| 422 | ABOLISHED_GST_RATE | 12% or 28% — abolished in September 2025 |
| 402 | plan_limit_reached | Company hit their monthly invoice limit — account admin must upgrade |
| 429 | RATE_LIMIT_EXCEEDED | See Rate Limits |
invoice.created webhook fires after creation (for drafts too).402.#POST/invoices/prefill
Create a 30-minute prefill token for deal-to-invoice deep linking. Call this from your backend when a user opens the invoice tab on a deal, then pass the token as entityId in the embed JWT with context=deal.
Request body
{
"partnerTenantId": "company_123",
"partnerContactId": "contact_456",
"partnerDealId": "deal_789",
"lineItems": [
{
"partnerItemId": "item_001",
"description": "Paracetamol 500mg (strip of 10)",
"quantity": 2,
"rate": 25
}
],
"dealValue": 50000,
"dueDate": "2026-07-31"
}Response
{ "prefillToken": "a3f8c2d1...", "expiresIn": 1800 }Deal-to-invoice flow
- 1User opens "GST Invoice" tab on a deal in your CRM.
- 2Your backend calls POST /invoices/prefill → gets prefillToken.
- 3Your backend generates embed JWT with: context: "deal", entityId: prefillToken.
- 4iframe opens → WeSaveTax reads the token → invoice builder opens pre-filled with buyer + line items.
- 5User clicks Create → WESAVETAX_INVOICE_CREATED fires.
POST /invoices (partnerItemId or savedItemId; unknown ids return 400 ITEM_NOT_FOUND).#GET/invoice-fields
Returns the invoice field schema — the complete list of fields a partner-built invoice form must render to produce a valid POST /invoices payload. Use this to dynamically drive your form rather than hardcoding field definitions.
Auth
Requires a valid partner API key (Bearer token). No partnerTenantId needed — the schema is not tenant-specific.
Response
{
"lineItemFields": [
{ "key": "partnerItemId", "label": "Item", "type": "text", "required": false, "maxLength": 200, "hint": "Your item id from items/upsert — links the line to the item so stock moves; omitted unit, HSN and GST rate come from the item" },
{ "key": "description", "label": "Description", "type": "text", "required": true, "maxLength": 2000 },
{ "key": "quantity", "label": "Quantity", "type": "number", "required": true, "min": 1, "max": 999999 },
{ "key": "rate", "label": "Rate (₹)", "type": "number", "required": true, "min": 0 },
{
"key": "gstRate",
"label": "GST Rate (%)",
"type": "select",
"required": false,
"options": [
{ "label": "0%", "value": 0 },
{ "label": "0.25%", "value": 0.25 },
{ "label": "3%", "value": 3 },
{ "label": "5%", "value": 5 },
{ "label": "18%", "value": 18 },
{ "label": "40%", "value": 40 }
]
},
{ "key": "unit", "label": "Unit", "type": "text", "required": false, "maxLength": 50, "hint": "For a linked item, omit it or send the item's exact unit" },
{ "key": "hsn", "label": "HSN / SAC Code", "type": "text", "required": false, "maxLength": 20, "hint": "Harmonized System Nomenclature (goods) or Service Accounting Code" }
],
"headerFields": [
{ "key": "dueDate", "label": "Due Date", "type": "date", "required": false, "hint": "ISO 8601 date string, e.g. 2026-07-31" },
{ "key": "notes", "label": "Notes", "type": "textarea", "required": false, "maxLength": 5000 },
{ "key": "poNumber","label": "PO Number","type": "text", "required": false, "maxLength": 50, "hint": "Buyer's purchase order reference" },
{ "key": "discount", "label": "Discount", "type": "object", "required": false,
"fields": [
{ "key": "type", "label": "Discount Type", "type": "select", "options": [{ "label": "Percent (%)", "value": "percent" }, { "label": "Flat (₹)", "value": "flat" }] },
{ "key": "value", "label": "Discount Value", "type": "number", "min": 0 }
]
}
]
}gstRate options. Using an abolished rate in POST /invoices returns 422 ABOLISHED_GST_RATE.Error responses
| Status | Meaning |
|---|---|
| 401 | Missing or invalid API key |
| 429 | Rate limit exceeded |
#POST/auth/exchange
Called automatically by the WeSaveTax embed — you do not need to call this from your backend. Documented here for reference.
Request body
{ "token": "eyJhbGci..." }Response
// Success
{ "status": "ok", "clientId": "..." }
// Company not provisioned yet — setup wizard will show
{ "status": "setup_required", "setupData": { ... } }#Webhooks
WeSaveTax sends a POST request to your configured webhook URL when key events happen. All requests are signed with HMAC-SHA256.
Your webhook URL is set by WeSaveTax when your integration is provisioned (there is no partner self-service page for this yet) — separate URLs for live and sandbox. Contact WeSaveTax to change it. If the URL is a bare origin, WeSaveTax POSTs to /api/webhooks/wesavetax/{event} on it; if it already has a path, to {url}/{event}.
Example: if your webhook URL is https://crm.example.com, WeSaveTax will POST to:
Events
| Event | When |
|---|---|
invoice.created | An invoice is created — including drafts, which have invoiceNumber: null |
invoice.updated | An invoice is edited or issued (a draft's number arrives here) |
invoice.deleted | An invoice is deleted |
payment.recorded | A payment is recorded against an invoice |
payment.updated | A payment is edited |
payment.deleted | A payment is deleted |
payment.reversed | A payment is reversed — reversedFromId names the original |
payment.refunded | A refund is paid out against a credit note |
invoice.* events share the invoice.created payload; payment.* events share the payment.recorded payload.
Delivery and retries
Deliveries are queued and usually arrive within a minute; order is not guaranteed. Any non-2xx response or timeout is retried up to 6 attempts in total (after 30 s, 2 min, 10 min, 1 h, 6 h). eventId stays the same across retries — use it to ignore duplicates.
#invoice.created
{
"event": "invoice.created",
"eventId": "a1b2c3d4e5f6...",
"partnerId": "your_partner_id",
"timestamp": "2026-06-22T14:30:00.000Z",
"data": {
"taxEasyyInvoiceId": "64f1a2b3c4d5e6f7a8b9c0d3",
"invoiceNumber": "INV-2026-00001",
"grandTotal": 118000,
"status": "sent",
"partnerDealId": "deal_789",
"partnerContactId": "contact_456",
"buyerName": "Reliance Industries Ltd",
"invoiceDate": "2026-06-22T00:00:00.000Z"
}
}#payment.recorded
{
"event": "payment.recorded",
"eventId": "b2c3d4e5f6a1...",
"partnerId": "your_partner_id",
"timestamp": "2026-06-22T14:30:00.000Z",
"data": {
"taxEasyyPaymentId": "64f1a2b3c4d5e6f7a8b9c0d4",
"taxEasyyInvoiceId": "64f1a2b3c4d5e6f7a8b9c0d3",
"invoiceNumber": "INV-2026-00001",
"amountPaid": 118000,
"paymentMethod": "bank_transfer",
"paymentDate": "2026-06-22T14:30:00.000Z",
"invoiceStatus": "paid",
"amountDue": 0,
"reference": "UTR123456",
"reversedFromId": null,
"partnerDealId": "deal_789",
"partnerContactId": "contact_456"
}
}paymentMethod: cash · bank_transfer · upi · cheque · card · credit_card · debit_card · online_gateway · other. invoiceStatus is the invoice's payment status (unpaid · partial · paid). partnerDealId / partnerContactId are the invoice's, or null when it has none.#Signature Verification
${timestamp}.${eventId}.${rawBody} — NOT the raw body alone.timestamp and eventId come from the X-WeSaveTax-Timestamp and X-WeSaveTax-Event-Id headers (timestamp is Unix seconds, not the ISO string inside the JSON body). Signing the raw body alone will never match — verification will always fail.const crypto = require("crypto");
// Verify the signature against the RAW request body bytes plus the
// timestamp + eventId headers — all three are covered by the signature.
// Re-stringifying req.body (after express.json() parses it) will NOT
// byte-match what WeSaveTax signed, so you must capture the raw body first.
function verifyWebhookSignature(rawBody, signatureHeader, timestampHeader, eventIdHeader, secret) {
if (typeof signatureHeader !== "string") return false;
// Header format: "sha256=<hex>". Strip the "sha256=" prefix before decoding.
const prefix = "sha256=";
if (!signatureHeader.startsWith(prefix)) return false;
const providedHex = signatureHeader.slice(prefix.length);
const signedMessage = `${timestampHeader}.${eventIdHeader}.${rawBody}`;
const expectedHex = crypto
.createHmac("sha256", secret)
.update(signedMessage) // rawBody here is a Buffer of the exact bytes we signed
.digest("hex");
// timingSafeEqual throws when lengths differ — guard first.
const a = Buffer.from(providedHex, "hex");
const b = Buffer.from(expectedHex, "hex");
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
// Express handler — use express.raw() so req.body is a Buffer of the exact
// bytes WeSaveTax signed. Do NOT use express.json() on this route; parse
// JSON only AFTER verification passes.
app.post(
"/api/webhooks/wesavetax/:event",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.headers["x-wesavetax-signature"];
const timestampHdr = req.headers["x-wesavetax-timestamp"]; // Unix seconds
const eventIdHdr = req.headers["x-wesavetax-event-id"];
if (!verifyWebhookSignature(req.body, signature, timestampHdr, eventIdHdr, process.env.WESAVETAX_WEBHOOK_SECRET)) {
return res.status(401).json({ error: "Invalid signature" });
}
// Recommended: reject payloads older than 5 minutes to limit replay
// exposure if a signed request is captured in transit. timestampHdr is
// covered by the signature (tamper-proof), unlike a value read from the
// JSON body before verification.
if (Date.now() - Number(timestampHdr) * 1000 > 5 * 60 * 1000) {
return res.status(400).json({ error: "Payload too old" });
}
// Signature is valid — safe to parse the JSON body.
const payload = JSON.parse(req.body.toString("utf8"));
const { event, data } = payload;
switch (event) {
case "invoice.created":
await Deal.findOneAndUpdate(
{ _id: data.partnerDealId },
{ invoiceNumber: data.invoiceNumber, invoiceId: data.taxEasyyInvoiceId }
);
break;
case "payment.recorded":
await Deal.findOneAndUpdate(
{ _id: data.partnerDealId },
{ paymentStatus: data.invoiceStatus, amountPaid: data.amountPaid }
);
break;
}
res.json({ received: true });
}
);WESAVETAX_WEBHOOK_SECRET andWESAVETAX_WEBHOOK_SECRET_PREVIOUS and treat verification as passing when either HMAC matches the signature.#postMessage Events
The WeSaveTax embed communicates with your parent window via window.postMessage. Set up a listener in your iframe wrapper component.
window.addEventListener("message", (event) => {
// Always verify the origin
if (event.origin !== "https://staging.wesavetax.com") return;
const { type, ...data } = event.data;
switch (type) {
case "WESAVETAX_READY":
// User authenticated and the portal is loading
hideLoadingSpinner();
break;
case "WESAVETAX_INVOICE_CREATED":
// data: { invoiceId, invoiceNumber, grandTotal, partnerDealId }
updateDealWithInvoice(data);
break;
case "WESAVETAX_PAYMENT_RECORDED":
// data: { invoiceId, invoiceNumber, amountPaid, invoiceStatus, partnerDealId }
updateDealPaymentStatus(data);
break;
case "WESAVETAX_SESSION_EXPIRED":
// Fetch a new token and reload the iframe
refreshEmbedToken();
break;
case "WESAVETAX_SESSION_INVALID":
// Token missing, malformed, or rejected — reopen the panel from your CRM
closeEmbedPanel();
break;
}
});#WESAVETAX_READY
User authenticated and the portal is loading. For a company's first visit it fires after the setup wizard is submitted — so keep the iframe visible before READY, or new users won't see the wizard.
Payload: none
#WESAVETAX_INVOICE_CREATED
Invoice saved or issued.
| Payload field | Description |
|---|---|
invoiceId | WeSaveTax invoice ID |
invoiceNumber | Invoice number string |
grandTotal | Total amount including GST |
partnerDealId | Your deal ID (if linked) |
#WESAVETAX_PAYMENT_RECORDED
Payment recorded against an invoice.
| Payload field | Description |
|---|---|
invoiceId | WeSaveTax invoice ID |
invoiceNumber | Invoice number string |
amountPaid | Amount paid in this payment |
invoiceStatus | Updated invoice status (e.g. paid) |
partnerDealId | Your deal ID (if linked) |
#WESAVETAX_SESSION_EXPIRED
User session expired inside the embed.
Payload: none. Fetch a new token and reload the iframe src.
#WESAVETAX_SESSION_INVALID
The token was missing, malformed, or rejected.
Payload: none. Also sent when a token is reused (tokens are single-use — mint a new one per load) or when WeSaveTax is briefly unavailable. If a fresh token keeps failing, check the signing secret and that the JWT partnerId matches it (live vs sandbox).
Note: WESAVETAX_INVOICE_CREATED and WESAVETAX_PAYMENT_RECORDED only fire when the invoice has a partnerDealId — i.e. when opened via a deal deep link. They do not fire for invoices created directly from the billing dashboard.
#Allowed Domains
For security, WeSaveTax restricts which domains can embed the portal via iframe. Send your domains to WeSaveTax to add them to your allow-list.
| Pattern | What it matches |
|---|---|
*.dxyra.com | app.dxyra.com, crm.dxyra.com, any subdomain — and dxyra.com itself |
dxyra.com | Apex domain only |
*.localhost | dentist11.localhost:3000, any subdomain of localhost (automatically allowed in sandbox mode) |
https://app.dxyra.com | Exact match with protocol |
http://localhost:*, http://*.localhost:*) for local development. You only need to add localhost to Allowed Domains in live mode.#Rate Limits
Requests are counted per partner id per hour — live and sandbox separately — across every /api/connect/v1 endpoint, including the embed's own /auth/exchange call on each load. The standard limit is 1,000 requests per hour; contact WeSaveTax for a higher tier.
// 429 Too Many Requests
// Headers: Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
{
"error": "Rate limit exceeded",
"code": "RATE_LIMIT_EXCEEDED",
"limit": 1000,
"remaining": 0,
"resetAt": "2026-06-22T15:00:00.000Z",
"retryAfter": 1234
}#Testing & Sandbox
Use sandbox credentials (format: txe_partner_yourname_sandbox_*) for all testing. Sandbox mode:
- → Invoices are marked as test documents
- → Never appear in GST reports (GSTR-1, GSTR-3B)
- → A "🧪 Sandbox Mode" banner shows in the setup wizard
- → Webhooks fire to your configured webhook URL normally
Local webhook testing with ngrok
# Start ngrok on your webhook port
ngrok http 5001
# Ask WeSaveTax to point your sandbox webhook URL at your ngrok URL:
# https://a1b2c3d4.ngrok-free.app
# Watch incoming webhooks
ngrok http 5001 --inspectFull test checklist
{ valid: true }#Going Live
event.origin !== "https://www.wesavetax.com"