WeSaveTax
Back to portal

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

1
Get credentials
WeSaveTax issues your API Key, Embed Secret, and Webhook Secret — a live set and a sandbox set.
2
Sync data
Push your buyers and items via the REST API before embedding.
3
Embed & listen
Render the iframe in your frontend and listen for postMessage events.

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

  1. 1Company completes your onboarding form with business details.
  2. 2Your backend calls POST /provision with all their info — business name, GSTIN, address, bank details, logo URL.
  3. 3WeSaveTax creates a fully configured billing account with billing profile populated.
  4. 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

json
{
  "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"
}
Tip:Host the logo on your own storage and pass the 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

CredentialFormatUsed For
API Keytxe_partner_{id}_{hex}Server-to-server API calls. Send as Authorization: Bearer header.
Embed Secret64 hex charsSign iframe JWT tokens with HS256.
Webhook Secret32 hex charsVerify 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.

Warning:Credentials are shown once on creation. Store them securely in environment variables. Never commit them to git.

#Embed Integration

#How It Works

  1. 1Your backend generates a signed JWT (30 min max expiry, unique jti per token) using Embed Secret.
  2. 2Your frontend renders an iframe: https://staging.wesavetax.com/embed?token={jwt} (production: https://www.wesavetax.com/embed?token={jwt}).
  3. 3WeSaveTax verifies the JWT, authenticates the user, loads the portal.
  4. 4User interacts with full WeSaveTax portal inside your app.
  5. 5Events fire back via window.postMessage.

#JWT Token Format

js
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 valueentityId required?What opens in the embed
billing (default)NoFull WeSaveTax dashboard
dealYes — prefillToken from POST /invoices/prefillInvoice builder pre-filled with deal buyer + line items
contactYes — taxEasyyBuyerIdBuyer 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.

Tip:Pre-fill 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:

js
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

Base URLStaging: https://staging.wesavetax.com/api/connect/v1Production: https://www.wesavetax.com/api/connect/v1
Auth headerAuthorization: Bearer {your_api_key}

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.

MeaningWrite name (preferred)Read name (also accepted)Where
Item or line nametitlenameitems/upsert, items/sync
Line descriptiondescriptionnameinvoices (lineItems)
HSN / SAC codehsnhsnCodeitems/upsert, items/sync, invoices (lineItems)
WeSaveTax item idsavedItemIdtaxEasyyItemIdinvoices (lineItems)
Note:Unrecognised fields are ignored, not rejected. A field we don't know (for example 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.

bash
curl https://staging.wesavetax.com/api/connect/v1/verify \
  -H "Authorization: Bearer txe_partner_yourname_sandbox_abc123"

Response

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

json
{
  "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"
}
Note:All fields except partnerTenantId, partnerUserEmail, and businessName are optional.

Responses

json
// 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": "..." }
Note:Provisioning is create-only: calling it again for the same partnerTenantId returns the existing account and does not change any details.

Refusals

StatuscodeMeaning
409EMAIL_EXISTS_NEEDS_CONSENTThe email already has its own WeSaveTax account
409EMAIL_BELONGS_TO_OTHER_PARTNERThe email belongs to another partner's company
409EMAIL_EXISTS_TENANT_MISMATCHThe email is already used by a different partnerTenantId of yours
409GSTIN_ALREADY_PROVISIONEDThe GSTIN already has a WeSaveTax account
403PARTNER_CLIENT_LIMIT_REACHEDYour live company limit is reached — contact WeSaveTax

#GET/billing-profile

Read a company's billing profile — business details, bank info, and logo.

Query params

ParamRequiredDescription
partnerTenantIdYesYour own id for the end customer's business — one per company

Response

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

ParamRequiredDescription
partnerTenantIdYesYour own id for the end customer's business — one per company
pageNoPage number (default: 1)
limitNoResults per page (default: 50, max: 100)
searchNoSearch by buyer name or GSTIN

Response

json
{
  "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 }
}
Note:gstRegistrationType values: regular · composition · unregistered · consumer · sez · government (default: unregistered)

#GET/buyers/:id

Fetch a single buyer by their taxEasyyBuyerId.

Query params

ParamRequiredDescription
partnerTenantIdYesYour 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

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

json
{ "taxEasyyBuyerId": "64f1a2b3c4d5e6f7a8b9c0d1", "isNew": true }
FieldRequiredNotes
partnerContactIdYesYour id for the contact. It is how we match this buyer on later calls
legalNameYesShown as the buyer's name in WeSaveTax (both the B2B and B2C lists)
gstinNoWith a GSTIN the buyer is B2B; without one it is B2C unless you send buyerType
gstRegistrationTypeNoregular, composition, unregistered, consumer, sez or government
buyerTypeNoB2B or B2C. An update never changes the type unless you send this
pan, email, phoneNoOptional contact and tax details
billingAddressNostreet, city, state, stateCode, pincode, country
gstStateCodeNoTwo-digit state code. Defaults to the first two digits of the GSTIN, then billingAddress.stateCode
Note:How a buyer is matched. First by 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.
Note:Store 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

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

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

ParamRequiredDescription
partnerTenantIdYesYour own id for the end customer's business — one per company
pageNoPage number (default: 1)
limitNoResults per page (default: 50, max: 100)
searchNoSearch by item name

Response

json
{
  "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 }
}
Note:API field names are 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.
Note: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

ParamRequiredDescription
partnerTenantIdYesYour 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

json
{
  "partnerTenantId": "company_123",
  "partnerItemId":   "item_789",
  "title":           "Paracetamol 500mg (strip of 10)",
  "rate":            25,
  "unit":            "PAC",
  "hsn":             "3004",
  "gstRate":         5
}
FieldRequiredNotes
partnerItemIdYesYour id for the item — also how invoice lines refer to it
titleYesItem name (max 200). Must be unique within the company
rateYesSelling price per unit
hsnNoHSN (goods) or SAC (services) code — find it with GET /hsn. A SAC code (starting 99) makes the item a service
gstRateNo0, 0.25, 3, 5, 18 or 40
unitNoAn official GST unit code (default NOS) — see below
descriptionNoMax 1000

Response

json
{ "taxEasyyItemId": "64f1a2b3c4d5e6f7a8b9c0d2", "isNew": true }
Note:Send 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.
Note:Set on creation only: 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

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

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

ParamRequiredDescription
qNoCode or description text, e.g. 3004 or paracetamol
limitNoResults (default: 10, max: 20)
json
{
  "data": [
    { "hsn": "3004", "type": "HSN", "description": "Medicaments in measured doses", "gstRate": 5 }
  ]
}
Note: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

ParamRequiredDescription
partnerTenantIdYesYour own id for the end customer's business — one per company
pageNoPage number (default: 1)
limitNoResults per page (default: 50, max: 100)
statusNoFilter by status: draft, sent, partially_paid, paid
partnerDealIdNoFilter by your deal ID
includeTestNoInclude sandbox invoices (default: false)

Response

json
{
  "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 }
}
Note:Test/sandbox invoices are excluded by default. Pass 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

ParamRequiredDescription
partnerTenantIdYesYour own id for the end customer's business — one per company

Response

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

StatusMeaning
401Missing or invalid API key
400partnerTenantId query param missing
404Invoice not found, or belongs to a different partner/tenant
429Rate 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

ParamRequiredDescription
partnerTenantIdYesYour 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".

bash
# 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.pdf

Using 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.

js
// 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

StatusMeaning
401Missing or invalid API key
400partnerTenantId query param missing
404Invoice not found, or belongs to a different partner/tenant
429Rate limit exceeded
500PDF 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

MethodFieldWhen to use
By WeSaveTax buyer IDbuyerIdUse taxEasyyBuyerId from a previous /buyers/upsert
By your contact IDpartnerContactIdIf the contact was already synced
Inline buyer objectbuyer: { ... }One-off buyer, no prior sync needed

Request body

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

json
{
  "taxEasyyInvoiceId": "64f1a2b3c4d5e6f7a8b9c0d3",
  "invoiceNumber":     "INV-2026-00001",
  "grandTotal":        59000,
  "status":            "sent",
  "paymentStatus":     "unpaid",
  "invoiceDate":       "2026-06-22T10:34:32.711Z",
  "partnerDealId":     "deal_789",
  "warnings":          []
}
Note:Check 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.
Note:HSN/SAC on the invoice and PDF comes from the line. Send it as 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

StatusErrorMeaning
400Validation failedBody doesn't match the schema — see details
400BUYER_REQUIREDNo buyer specified in request body
400BUYER_NOT_FOUNDbuyerId or partnerContactId matches no buyer
400ITEM_NOT_FOUNDpartnerItemId or savedItemId matches no item — see partnerItemIds / savedItemIds
404Tenant not foundInvalid or unknown partnerTenantId
403FEATURE_LOCKEDThe company's plan or business type doesn't include this (e.g. export supply types)
409TRANSACTION_CONFLICTTemporary conflict — safe to retry (retryable: true)
422INSUFFICIENT_STOCKNot enough stock for a linked item, and the company doesn't allow negative stock
422UNIT_NOT_CONFIGUREDThe line's unit isn't the item's unit
422ABOLISHED_GST_RATE12% or 28% — abolished in September 2025
402plan_limit_reachedCompany hit their monthly invoice limit — account admin must upgrade
429RATE_LIMIT_EXCEEDEDSee Rate Limits
Note:GST is calculated automatically based on buyer location and seller GSTIN — CGST+SGST for intra-state, IGST for inter-state. Invoice numbering follows the company's configured prefix and sequence. An invoice.created webhook fires after creation (for drafts too).
Warning:Headless invoices count against the company's monthly plan limit, exactly like invoices created in the embed. If the limit is reached, this endpoint returns 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

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

json
{ "prefillToken": "a3f8c2d1...", "expiresIn": 1800 }

Deal-to-invoice flow

  1. 1User opens "GST Invoice" tab on a deal in your CRM.
  2. 2Your backend calls POST /invoices/prefill → gets prefillToken.
  3. 3Your backend generates embed JWT with: context: "deal", entityId: prefillToken.
  4. 4iframe opens → WeSaveTax reads the token → invoice builder opens pre-filled with buyer + line items.
  5. 5User clicks Create → WESAVETAX_INVOICE_CREATED fires.
Note:prefillToken expires in 30 minutes and works once. Generate it fresh each time the user opens the invoice tab. Lines link to items exactly as in 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

json
{
  "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 }
      ]
    }
  ]
}
Note:GST rates 12% and 28% were abolished in September 2025 (GST 2.0) and are intentionally absent from the gstRate options. Using an abolished rate in POST /invoices returns 422 ABOLISHED_GST_RATE.

Error responses

StatusMeaning
401Missing or invalid API key
429Rate 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

json
{ "token": "eyJhbGci..." }

Response

json
// Success
{ "status": "ok", "clientId": "..." }

// Company not provisioned yet — setup wizard will show
{ "status": "setup_required", "setupData": { ... } }
Note:The embed calls this endpoint automatically when it loads. Your frontend only needs to provide the token in the iframe URL.

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

https://crm.example.com/api/webhooks/wesavetax/invoice.created
https://crm.example.com/api/webhooks/wesavetax/payment.recorded

Events

EventWhen
invoice.createdAn invoice is created — including drafts, which have invoiceNumber: null
invoice.updatedAn invoice is edited or issued (a draft's number arrives here)
invoice.deletedAn invoice is deleted
payment.recordedA payment is recorded against an invoice
payment.updatedA payment is edited
payment.deletedA payment is deleted
payment.reversedA payment is reversed — reversedFromId names the original
payment.refundedA 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

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

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

Warning:The signed message is ${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.
js
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 });
  }
);
Warning:Always return a 2xx response quickly. WeSaveTax times out after 10 seconds. Process events asynchronously if needed.
Warning:Webhook secrets can be rotated with a 7-day grace period from the WeSaveTax admin. During the grace window, WeSaveTax signs with the NEW secret only, but your verifier should accept EITHER the current or previous secret so in-flight deliveries are not dropped mid-rotation. Store both 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.

js
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 fieldDescription
invoiceIdWeSaveTax invoice ID
invoiceNumberInvoice number string
grandTotalTotal amount including GST
partnerDealIdYour deal ID (if linked)

#WESAVETAX_PAYMENT_RECORDED

Payment recorded against an invoice.

Payload fieldDescription
invoiceIdWeSaveTax invoice ID
invoiceNumberInvoice number string
amountPaidAmount paid in this payment
invoiceStatusUpdated invoice status (e.g. paid)
partnerDealIdYour 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:A page that frames the embed from a domain that isn't on your allow-list is blocked by the browser — the iframe stays blank and no event is sent.

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.

PatternWhat it matches
*.dxyra.comapp.dxyra.com, crm.dxyra.com, any subdomain — and dxyra.com itself
dxyra.comApex domain only
*.localhostdentist11.localhost:3000, any subdomain of localhost (automatically allowed in sandbox mode)
https://app.dxyra.comExact match with protocol
Note:Sandbox mode automatically allows all localhost origins (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.

json
// 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

bash
# 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 --inspect

Full test checklist

GET /verify → { valid: true }
POST /provision → creates test company
POST /buyers/upsert → syncs a contact
POST /items/upsert → syncs a product
GET /buyers → returns synced buyers
GET /items → returns synced items
Generate JWT → open embed URL in browser
Complete setup wizard (first time only)
POST /invoices → creates a headless invoice
GET /invoices → lists the created invoice
Create an invoice via embed → webhook fires invoice.created
Record a payment → webhook fires payment.recorded
GET /billing-profile → returns company details
Check postMessage events fire in browser console
Test session expiry handling

#Going Live

Switch to your live credentials — and the live partnerId in the embed JWT
Update environment variables:
WESAVETAX_BASE_URL=https://www.wesavetax.com
WESAVETAX_PARTNER_ID=yourname
WESAVETAX_API_KEY=txe_partner_yourname_xxx
WESAVETAX_EMBED_SECRET=<64 hex chars>
WESAVETAX_WEBHOOK_SECRET=<32 hex chars>
Ask WeSaveTax to add your production domain to Allowed Domainse.g. *.yourdomain.com
Update iframe origin check in postMessage listener:
event.origin !== "https://www.wesavetax.com"
Test once with a real company before full rollout
WeSaveTax — ITR, GST, Audit & Tax Advisory