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
From the WeSaveTax partner portal: API Key, Embed Secret, and Webhook Secret.
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 — they land directly in the portal, no setup wizard.

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",
    "country": "India"
  },
  "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.
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 (5 min max expiry, unique jti per token) using Embed Secret.
  2. 2Your frontend renders an iframe: https://staging.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 company's unique ID
    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: "5m", algorithm: "HS256" } // Max 5 min — 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
contactNo (coming soon)Buyer 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 invoice numbering (prefix + starting number)

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 user's session expires inside the embed, 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 URLhttps://staging.wesavetax.com/api/connect/v1
Auth headerAuthorization: Bearer {your_api_key}

All requests: Content-Type: application/json

#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",
    "country": "India"
  },
  "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, "clientId": "...", "billingProfileId": "..." }

// Company already exists
{ "alreadyExists": true, "clientId": "..." }

// Email linked to existing account
{ "linked": true, "clientId": "..." }

#GET/billing-profile

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

Query params

ParamRequiredDescription
partnerTenantIdYesYour company's unique ID

Response

json
{
  "businessName": "ABC Traders",
  "legalName":    "ABC Traders Pvt Ltd",
  "gstin":        "27AAACR5055K1Z5",
  "state":        "Maharashtra",
  "address":      "123 MG Road",
  "city":         "Mumbai",
  "pincode":      "400001",
  "country":      "India",
  "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 company's unique ID
pageNoPage number (default: 1)
limitNoResults per page (default: 20, 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": 20, "total": 45, "totalPages": 3 }
}
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 company's unique ID

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 }
Note:Store taxEasyyBuyerId in your CRM — use it to avoid re-syncing the same contact repeatedly.

#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, "failed": 0, "errors": [] }

#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 company's unique ID
pageNoPage number (default: 1)
limitNoResults per page (default: 20, 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",
      "createdAt":      "2026-06-22T10:30:00.000Z"
    }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 45, "totalPages": 3 }
}
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. 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.

#GET/items/:id

Fetch a single item by its taxEasyyItemId.

Query params

ParamRequiredDescription
partnerTenantIdYesYour company's unique ID

Response

Single item object — same shape as a list item (includes imageUrl).

#POST/items/upsert

Sync a product or service from your catalogue.

Request body

json
{
  "partnerTenantId": "company_123",
  "partnerItemId":   "item_789",
  "name":            "Website Design",
  "rate":            50000,
  "unit":            "NOS",
  "hsnCode":         "998314",
  "gstRate":         18
}

Response

json
{ "taxEasyyItemId": "64f1a2b3c4d5e6f7a8b9c0d2", "isNew": true }

#POST/items/sync

Bulk upsert up to 500 items in one call.

Request body

json
{
  "partnerTenantId": "company_123",
  "items": [
    { "partnerItemId": "item_001", "name": "Web Design", "rate": 50000, "gstRate": 18 },
    { "partnerItemId": "item_002", "name": "SEO Package", "rate": 15000, "gstRate": 18 }
  ]
}

Response

json
{ "synced": 2, "failed": 0, "errors": [] }

#GET/invoices

List invoices for a company.

Query params

ParamRequiredDescription
partnerTenantIdYesYour company's unique ID
pageNoPage number (default: 1)
limitNoResults per page (default: 20, max: 100)
statusNoFilter by status: draft, sent, paid, cancelled
partnerDealIdNoFilter by your deal ID
includeTestNoInclude sandbox invoices (default: false)

Response

json
{
  "data": [
    {
      "taxEasyyInvoiceId": "64f1a2b3c4d5e6f7a8b9c0d3",
      "invoiceNumber":     "INV-2026-00001",
      "invoiceDate":       "2026-06-22",
      "buyerName":         "Reliance Industries Ltd",
      "grandTotal":        118000,
      "amountPaid":        0,
      "amountDue":         118000,
      "paymentStatus":     "unpaid",
      "status":            "sent",
      "partnerDealId":     "deal_789",
      "partnerContactId":  "contact_456"
    }
  ],
  "pagination": { "page": 1, "limit": 20, "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 company's unique ID

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://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 company's unique ID

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://wesavetax.com/api/connect/v1/invoices/64f1a2b3.../pdf?partnerTenantId=company_123 \
  -H "Authorization: Bearer txe_partner_dxyra_live_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
  "lineItems": [
    {
      "description": "Website Design",
      "quantity":    1,
      "rate":        50000,
      "gstRate":     18,
      "unit":        "NOS",
      "hsnCode":     "998314"
    }
  ],
  "dueDate": "2026-07-31"                  // optional
}

Response (201)

json
{
  "taxEasyyInvoiceId": "64f1a2b3c4d5e6f7a8b9c0d3",
  "invoiceNumber":     "INV-2026-00001",
  "grandTotal":        59000,
  "status":            "draft",
  "paymentStatus":     "unpaid",
  "invoiceDate":       "2026-06-22",
  "partnerDealId":     "deal_789"
}

Error responses

StatusErrorMeaning
400BUYER_REQUIREDNo buyer specified in request body
404Tenant not foundInvalid or unknown partnerTenantId
402plan_limit_reachedCompany hit their monthly invoice limit — account admin must upgrade
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 automatically after creation.
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",
      "name":          "Website Design",
      "quantity":      1,
      "rate":          50000
    }
  ],
  "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. Generate it fresh each time the user opens the invoice tab.

#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. Field types, options, and constraints are derived from the same source used by the create endpoint, so they can never drift.

Auth

Requires a valid partner API key (Bearer token). No partnerTenantId needed — the schema is not tenant-specific.

Response

json
{
  "lineItemFields": [
    { "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": "e.g. NOS, PCS, KG, HR, MTR" },
    { "key": "hsn",      "label": "HSN / SAC Code",  "type": "text", "required": false, "maxLength": 20,   "hint": "Harmonized System Nomenclature (goods) or SAC (services)" },
    { "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 }
      ]
    }
  ],
  "headerFields": [
    { "key": "dueDate", "label": "Due Date", "type": "date",     "required": false, "hint": "ISO 8601, 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" }
  ]
}
Note:GST rates 12% and 28% were abolished in September 2025 (GST 2.0) and are intentionally absent from the gstRate options. Using abolished rates in POST /invoices will result in a validation error.

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. WeSaveTax will POST to that URL with path /api/webhooks/wesavetax/{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

#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-22"
  }
}

#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",
    "paymentDate":       "2026-06-22T14:30:00.000Z",
    "invoiceStatus":     "paid",
    "amountDue":         0,
    "partnerDealId":     "deal_789",
    "partnerContactId":  "contact_456"
  }
}

#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 5 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":
      // Embed loaded and user authenticated — safe to show the iframe
      iframe.style.opacity = "1";
      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_HEIGHT_CHANGE":
      // data: { height } — resize iframe to match content
      iframe.style.height = data.height + "px";
      break;
  }
});

#WESAVETAX_READY

Embed loaded and user authenticated — safe to show the iframe.

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_HEIGHT_CHANGE

Content height changed — resize the iframe to match.

Payload fieldDescription
heightNew content height in pixels (number)

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. Configure your domains in the partner portal under "Allowed Domains".

PatternWhat it matches
*.dxyra.comapp.dxyra.com, crm.dxyra.com, any subdomain
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.

#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 inside the embed
  • Webhooks fire to your configured webhook URL normally

Local webhook testing with ngrok

bash
# Start ngrok on your webhook port
ngrok http 5001

# Update webhook URL in partner portal to 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

Generate live credentials from partner portal
Update environment variables:
WESAVETAX_BASE_URL=https://wesavetax.com
WESAVETAX_API_KEY=txe_partner_yourname_live_xxx
WESAVETAX_EMBED_SECRET=<64 hex chars>
WESAVETAX_WEBHOOK_SECRET=<32 hex chars>
Add production domain to Allowed Domains in partner portale.g. *.yourdomain.com
Update iframe origin check in postMessage listener:
event.origin !== "https://wesavetax.com"
Test once with a real company before full rollout
Monitor webhook delivery in partner portal logs
WeSaveTax — ITR, GST, Audit & Tax Advisory