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 — they land directly in the portal, no setup wizard.
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",
"country": "India"
},
"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. |
#Embed Integration
#How It Works
- 1Your backend generates a signed JWT (5 min max expiry, unique jti per token) using Embed Secret.
- 2Your frontend renders an iframe: https://staging.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 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 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 | No (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.
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:
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
#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",
"country": "India"
},
"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, "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
| Param | Required | Description |
|---|---|---|
partnerTenantId | Yes | Your company's unique ID |
Response
{
"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
| Param | Required | Description |
|---|---|---|
partnerTenantId | Yes | Your company's unique ID |
page | No | Page number (default: 1) |
limit | No | Results per page (default: 20, 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": 20, "total": 45, "totalPages": 3 }
}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 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
{
"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 }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
{
"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, "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
| Param | Required | Description |
|---|---|---|
partnerTenantId | Yes | Your company's unique ID |
page | No | Page number (default: 1) |
limit | No | Results per page (default: 20, 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",
"createdAt": "2026-06-22T10:30:00.000Z"
}
],
"pagination": { "page": 1, "limit": 20, "total": 45, "totalPages": 3 }
}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
| Param | Required | Description |
|---|---|---|
partnerTenantId | Yes | Your 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
{
"partnerTenantId": "company_123",
"partnerItemId": "item_789",
"name": "Website Design",
"rate": 50000,
"unit": "NOS",
"hsnCode": "998314",
"gstRate": 18
}Response
{ "taxEasyyItemId": "64f1a2b3c4d5e6f7a8b9c0d2", "isNew": true }#POST/items/sync
Bulk upsert up to 500 items in one call.
Request body
{
"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
{ "synced": 2, "failed": 0, "errors": [] }#GET/invoices
List invoices for a company.
Query params
| Param | Required | Description |
|---|---|---|
partnerTenantId | Yes | Your company's unique ID |
page | No | Page number (default: 1) |
limit | No | Results per page (default: 20, max: 100) |
status | No | Filter by status: draft, sent, paid, cancelled |
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-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 }
}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 company's unique ID |
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://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 company's unique ID |
Response
Binary PDF stream. Content-Type: application/pdf. Content-Disposition: attachment; filename="INV-2026-00001.pdf".
# 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.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
"lineItems": [
{
"description": "Website Design",
"quantity": 1,
"rate": 50000,
"gstRate": 18,
"unit": "NOS",
"hsnCode": "998314"
}
],
"dueDate": "2026-07-31" // optional
}Response (201)
{
"taxEasyyInvoiceId": "64f1a2b3c4d5e6f7a8b9c0d3",
"invoiceNumber": "INV-2026-00001",
"grandTotal": 59000,
"status": "draft",
"paymentStatus": "unpaid",
"invoiceDate": "2026-06-22",
"partnerDealId": "deal_789"
}Error responses
| Status | Error | Meaning |
|---|---|---|
| 400 | BUYER_REQUIRED | No buyer specified in request body |
| 404 | Tenant not found | Invalid or unknown partnerTenantId |
| 402 | plan_limit_reached | Company hit their monthly invoice limit — account admin must upgrade |
invoice.created webhook fires automatically after creation.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",
"name": "Website Design",
"quantity": 1,
"rate": 50000
}
],
"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.
#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
{
"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" }
]
}gstRate options. Using abolished rates in POST /invoices will result in a validation error.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. 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:
#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-22"
}
}#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",
"paymentDate": "2026-06-22T14:30:00.000Z",
"invoiceStatus": "paid",
"amountDue": 0,
"partnerDealId": "deal_789",
"partnerContactId": "contact_456"
}
}#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":
// 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 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_HEIGHT_CHANGE
Content height changed — resize the iframe to match.
| Payload field | Description |
|---|---|
height | New 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".
| Pattern | What it matches |
|---|---|
*.dxyra.com | app.dxyra.com, crm.dxyra.com, any subdomain |
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.#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
# 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 --inspectFull test checklist
{ valid: true }#Going Live
event.origin !== "https://wesavetax.com"