API

Sending invoice events to your accounting system

Subscribe to invoice webhooks, verify each delivery, and post paid invoices into your ledger automatically — no polling, no double entry.

10 min read Updated June 2026

When an invoice is created, sent, or paid in evSuryam, your accounting system should know about it without anyone re-typing a thing. Webhooks make that automatic: evSuryam POSTs a signed JSON event to a URL you control the moment something happens. This guide is the end-to-end recipe — subscribe, verify, respond, and post the invoice into a ledger. It assumes you have read the API & integrations overview.

Overview

A webhook is just an HTTP endpoint on your server that evSuryam calls. The flow is:

  1. You register an HTTPS URL and pick which events you want.
  2. An invoice changes state in evSuryam (for example it gets paid).
  3. evSuryam POSTs a JSON body to your URL, signed with an HMAC.
  4. Your endpoint verifies the signature, does its work, and returns 2xx.
  5. If your endpoint fails or times out, evSuryam retries with backoff.
Subscribing to invoice webhook events.
Subscribing to invoice webhook events.

Why not just poll?Polling GET /v1/invoices every few minutes burns rate limit and still lags reality. A webhook fires in real time and tells you exactly which invoice changed — far cheaper and far fresher.

Create a webhook subscription

  1. Open Settings → API → Webhooks and click Add endpoint.
  2. Enter the HTTPS URL that will receive events, for example https://books.acme-solar.in/hooks/evsuryam.
  3. Select the events to subscribe to — for an accounting integration, invoice.paid is usually enough; add invoice.voided to reverse entries.
  4. Click Save. evSuryam shows a signing secret (for example whsec_3b1f...) once — copy it and store it as a server secret. You will use it to verify every delivery.
  5. Use Send test event to fire a sample invoice.paid at your URL and confirm it returns 2xx.

The signing secret is not the API tokenEach webhook endpoint has its own signing secret used only to compute the HMAC. Keep it out of source control. If it leaks, rotate it from the same screen — the old secret stops validating immediately.

Invoice event types

Subscribe to only the events your integration acts on. The full invoice lifecycle:

EventFires whenTypical ledger action
invoice.createdA tax invoice is issued.Create the sales voucher (unpaid).
invoice.sentThe invoice is emailed to the customer.Usually informational — update status.
invoice.paidThe invoice is fully settled.Mark paid / post the receipt.
invoice.payment_recordedA part-payment is logged against the invoice.Post a partial receipt.
invoice.voidedThe invoice is cancelled.Reverse or cancel the voucher.

Note the difference between invoice.payment_recorded (one payment was logged, the invoice may still have a balance) and invoice.paid (the balance has reached zero). For a clean ledger, post receipts off invoice.payment_recorded and use invoice.paid only to flip the invoice's status.

The payload shape

Every webhook body has the same outer shape: a top-level event name, a unique id, a created_at timestamp, and a data object holding the resource. For invoice events, data.invoice carries the full record:

{
  "id": "evt_01HF9A2Q7M3P0T8VBC4K2YR9BE",
  "event": "invoice.paid",
  "created_at": "2026-06-11T09:14:32+05:30",
  "data": {
    "invoice": {
      "id": "01HF8Z9Q4M2P0T7VBC3K1XR8AD",
      "number": "INV-2026-0184",
      "status": "paid",
      "customer": {
        "id": "01HF8Z6Y7N0E5R2QABV9MK3T1C",
        "name": "Anjali Desai",
        "gstin": null
      },
      "total": "248000.00",
      "gst_breakdown": {
        "taxable_value": "221428.57",
        "cgst": "13285.71",
        "sgst": "13285.72",
        "igst": "0.00"
      }
    }
  }
}

Key fields: id is the event id (use it for idempotency, below), data.invoice.id is the invoice's ULID, total and the gst_breakdown amounts are strings (parse to a decimal type, never a float), and status reflects the invoice state at the time the event fired.

Fetch, don't trust blindlyThe payload is a snapshot. If you need fields beyond those above, call GET /v1/invoices/{id} with the data.invoice.id to pull the authoritative, current record before posting.

Verifying the signature

Anyone who learns your URL could POST fake events to it. Verify that each delivery really came from evSuryam by checking the X-evSuryam-Signature header, an HMAC-SHA256 of the raw request body keyed with your endpoint's signing secret. Compute the same HMAC on your side and compare.

Node.js (Express):

const crypto = require('crypto');

// IMPORTANT: verify against the RAW body, before JSON parsing.
app.post('/hooks/evsuryam',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.header('X-evSuryam-Signature');
    const expected = crypto
      .createHmac('sha256', process.env.EVSURYAM_WEBHOOK_SECRET)
      .update(req.body) // req.body is a Buffer here
      .digest('hex');

    const ok = signature &&
      crypto.timingSafeEqual(
        Buffer.from(signature),
        Buffer.from(expected)
      );

    if (!ok) {
      return res.status(401).send('invalid signature');
    }

    const event = JSON.parse(req.body.toString('utf8'));
    // ... handle event.event, event.id, event.data.invoice
    return res.sendStatus(200);
  }
);

The same logic in PHP:

$payload   = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_EVSURYAM_SIGNATURE'] ?? '';
$expected  = hash_hmac('sha256', $payload, getenv('EVSURYAM_WEBHOOK_SECRET'));

if (! hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('invalid signature');
}

$event = json_decode($payload, true);
// $event['event'], $event['id'], $event['data']['invoice'] ...

Two rules that matterAlways hash the raw body — re-serialising parsed JSON changes the bytes and the signature will never match. And always compare with a constant-time function (hash_equals / timingSafeEqual), never ==, to avoid timing attacks.

Responding, retries & idempotency

Once you have verified the signature, return a 2xx status (200 or 204) as quickly as you can. evSuryam treats any non-2xx response — or a timeout past 10 seconds — as a failed delivery and retries it.

Retry & backoff: failed deliveries are retried with exponential backoff over roughly 24 hours (for example after 1 min, 5 min, 30 min, 2 h, 6 h). After the final attempt the delivery is marked failed; you can inspect and manually replay it from Settings → API → Webhooks.

Do the slow work asynchronously. Don't post to your accounting system inside the webhook request — verify, enqueue a background job, and return 200 immediately. This keeps you under the timeout and stops a slow ledger API from triggering retries.

Idempotency via event idBecause of retries, the same event can arrive more than once. Record each event.id (for example evt_01HF9A2Q...) in a table with a unique constraint and skip any id you have already processed. That makes duplicate deliveries harmless.

Worked example: posting to a ledger

Here is the full happy path for posting a paid invoice into an accounting ledger, in Node. It verifies the signature (above), guards against duplicates with the event id, then maps the payload to a ledger entry:

async function handlePaidInvoice(event) {
  // 1. Idempotency — skip if we've seen this event id before.
  const fresh = await db.insertIgnore('processed_events', { id: event.id });
  if (!fresh) return; // duplicate delivery, already handled

  const inv = event.data.invoice;

  // 2. Map evSuryam fields to a ledger entry. Amounts are strings —
  //    keep them as decimals, never parseFloat into a JS number.
  const entry = {
    reference:     inv.number,                 // INV-2026-0184
    party:         inv.customer.name,
    party_gstin:   inv.customer.gstin,
    taxable_value: inv.gst_breakdown.taxable_value,
    cgst:          inv.gst_breakdown.cgst,
    sgst:          inv.gst_breakdown.sgst,
    igst:          inv.gst_breakdown.igst,
    total:         inv.total,
    paid_on:       event.created_at,
  };

  // 3. Push to the accounting system (Tally / Zoho / your ERP).
  await ledger.createSalesVoucher(entry);
}

The CGST/SGST vs IGST split in gst_breakdown tells your ledger which tax heads to credit — intra-state sales populate cgst + sgst, inter-state sales populate igst. evSuryam has already done that determination; your job is only to map it onto the right ledger accounts. For the rules behind the split, see GST & compliance.

Next steps

With verified, idempotent webhook handling in place you have a reliable feed of invoice events. If you would rather not build the ledger mapping yourself, evSuryam's pre-built Tally and Zoho Books sync does it for you — see the API & integrations overview. And make sure your tax heads line up with GST & compliance before going live.

← Back to all resources

Ready to commission more, chase less?

14-day free trial. No credit card. Set up in under 10 minutes.

See plans Start free trial