# Hasabi ERP - Webhooks & Real-Time Event Streams (API Version: v1)

This technical specification provides the complete event catalogue, payload schemas, HMAC cryptographic signature verification protocols, and developer sandbox verification endpoints for **Hasabi ERP**.

---

## 1. Quick Reference & Core Rules

| Key | Specification | Description |
| :--- | :--- | :--- |
| **API Version** | `v1` | Standardized version for all payloads and event schemas. |
| **Signing Secret** | **Mandatory** | Every webhook endpoint **MUST** have a secret key (minimum 16 chars). Secrets are never optional. |
| **Signature Algorithm** | `HMAC-SHA256` | Calculated over `timestamp + "." + raw_body_bytes`. |
| **Signature Header** | `X-Hasabi-Signature` | Formatted as `t={unix_timestamp},v1={hex_hmac_hash}`. |
| **Replay Protection** | 300 seconds | Receivers should reject any payload where `|now - timestamp| > 300s` (5 minutes). |
| **Delivery Timeout** | 4,000 ms (4s) | Hasabi workers enforce a 4s strict timeout. Receiver must return `HTTP 200 OK` promptly. |
| **Circuit Breaker** | 10 fails / 50 fails | Endpoints degrade at 10 consecutive fails and auto-pause at 50 consecutive fails. |
| **Public Webhook Docs** | `GET /docs/webhooks` | Interactive Event Explorer (all 14 events) & live HMAC test tool. |
| **Public API Docs** | `GET /docs/api` | Live stock search, product lookup, categories, and authentication headers. |
| **OpenAPI 3.1 Spec** | `GET /docs/openapi.json` | Machine-readable schema specification for agents and Postman. |
| **Raw Markdown Spec** | `GET /docs/webhooks.md` | Downloadable raw markdown file for AI agents & LLMs. |
| **Sandbox Endpoint** | `GET /api/v1/webhooks/sample` | Live test payload & signature generator for developers. Requires `?sandbox=true&secret=...`. |


---

## 2. Cryptographic Signature Verification Protocol

Each webhook delivery contains four security and routing headers:

```http
POST /your-webhook-endpoint HTTP/1.1
Host: your-receiver-domain.com
Content-Type: application/json
User-Agent: Hasabi-Webhooks/1.0 (+https://hesabi.in)
X-Hasabi-Event: sale_invoice.created
X-Hasabi-Event-Id: evt_01j7xyz947a1b2c3d4e5f6g7h8
X-Hasabi-Delivery: d8f4e2a1-63b7-4b89-a2f0-123456789abc
X-Hasabi-Timestamp: 1758880000
X-Hasabi-Signature: t=1758880000,v1=5d41402abc4b2a76b9719d911017c592...
X-Company-ID: 1
```

### Verification Algorithm:
1. Extract `timestamp` from `X-Hasabi-Timestamp` and `signature` (`v1`) from `X-Hasabi-Signature`.
2. Ensure timestamp is not older than 300 seconds: `abs(current_time - timestamp) <= 300`.
3. Concatenate: `signed_payload = timestamp + "." + raw_request_body_bytes`.
4. Calculate: `expected_signature = hash_hmac("sha256", signed_payload, webhook_secret)`.
5. Perform constant-time string comparison (`hash_equals` / `timingSafeEqual` / `hmac.compare_digest`).

---

## 3. Developer Sandbox GET Endpoint (`?sandbox=true`)

Developers can verify their webhook receivers locally without modifying the production database.

### Request:
```http
GET /api/v1/webhooks/sample?event=sale_invoice.created&sandbox=true&secret=your_hmac_secret_key HTTP/1.1
Host: localhost:8000
```

> **Note:** The `secret` query parameter is **required**. If omitted, the endpoint returns `HTTP 422 Unprocessable Entity`.

### Response:
```json
{
  "success": true,
  "sandbox": true,
  "api_version": "v1",
  "event": "sale_invoice.created",
  "mock_request_headers": {
    "Content-Type": "application/json",
    "User-Agent": "Hasabi-Webhooks/1.0 (+https://hesabi.in)",
    "X-Hasabi-Event": "sale_invoice.created",
    "X-Hasabi-Event-Id": "evt_sandbox_01j7xyz947a1b2c3d4e5f6g7h8",
    "X-Hasabi-Delivery": "78c99182-3841-4122-b50a-88f12a345678",
    "X-Hasabi-Timestamp": "1758880000",
    "X-Hasabi-Signature": "t=1758880000,v1=a1b2c3d4e5f6...",
    "X-Company-ID": "1"
  },
  "payload": {
    "id": "evt_sandbox_01j7xyz947a1b2c3d4e5f6g7h8",
    "event": "sale_invoice.created",
    "api_version": "v1",
    "is_sandbox": true,
    "timestamp": "2026-09-26T11:45:00+06:00",
    "company": {
      "id": 1,
      "name": "Hasabi Demo Retail Ltd",
      "phone": "9064313651"
    },
    "entity_type": "sale_invoice",
    "data": { ... }
  },
  "curl_test_command": "curl -X POST http://localhost:3000/webhooks/hasabi -H 'Content-Type: application/json' -H 'X-Hasabi-Event: sale_invoice.created' -H 'X-Hasabi-Timestamp: 1758880000' -H 'X-Hasabi-Signature: t=1758880000,v1=...' -d '{...}'"
}
```

---

## 4. Canonical JSON Payload Specification

All webhook events wrap entity information inside a standardized top-level envelope:

```typescript
interface HasabiWebhookEnvelope<T> {
  id: string;               // Unique ULID (e.g. "evt_01j7xyz...")
  event: string;            // Event name (e.g. "sale_invoice.created")
  api_version: "v1";        // Always "v1"
  timestamp: string;        // ISO 8601 string
  company: {                // Tenant Company Context
    id: number | null;
    name: string;
    phone: string | null;
  };
  entity_type: string;      // "sale_invoice" | "purchase_bill" | "party" | "item"
  data: T;                  // Normalized entity attributes
  changes?: Record<string, { previous: any; current: any }>; // Diff for *.updated events
}
```

---

## 5. Event Catalogue & Full JSON Payloads

### 🧾 Transactions

#### 1. `sale_invoice.created` (also triggers `transaction.created`, `transaction.*`)
```json
{
  "id": "evt_01j7xyz947a1b2c3d4e5f6g7h8",
  "event": "sale_invoice.created",
  "api_version": "v1",
  "timestamp": "2026-09-26T11:45:00+06:00",
  "company": {
    "id": 1,
    "name": "Rabi Mobile",
    "phone": "9064313651"
  },
  "entity_type": "sale_invoice",
  "data": {
    "id": 1042,
    "transaction_type": "sale_invoice",
    "document_number": "INV-1042",
    "date": "2026-09-26",
    "due_date": "2026-10-11",
    "party": {
      "id": 15,
      "name": "John Doe",
      "phone": "9876543210",
      "email": "johndoe@example.com",
      "category": "Customer"
    },
    "total_amount": 1770.00,
    "paid_amount": 1000.00,
    "balance_amount": 770.00,
    "status": "partial",
    "payment_mode": "Cash",
    "items_count": 1,
    "items": [
      {
        "item_id": 108,
        "item_name": "Samsung Display 120Hz",
        "quantity": 1.0,
        "unit": "PCS",
        "rate": 1500.00,
        "tax_rate": 18.0,
        "tax_amount": 270.00,
        "discount": 0.0,
        "total": 1770.00
      }
    ]
  }
}
```

#### 2. `purchase_bill.created`
```json
{
  "id": "evt_01j7xyz947a1b2c3d4e5f6g7h8",
  "event": "purchase_bill.created",
  "api_version": "v1",
  "timestamp": "2026-09-26T11:45:00+06:00",
  "company": { "id": 1, "name": "Rabi Mobile", "phone": "9064313651" },
  "entity_type": "purchase_bill",
  "data": {
    "id": 840,
    "transaction_type": "purchase_bill",
    "document_number": "BILL-840",
    "date": "2026-09-26",
    "due_date": "2026-10-26",
    "party": {
      "id": 22,
      "name": "Supreme Mobile Wholesale Ltd",
      "phone": "9832100000",
      "category": "Supplier"
    },
    "total_amount": 12500.00,
    "paid_amount": 12500.00,
    "balance_amount": 0.00,
    "status": "paid",
    "payment_mode": "Bank Transfer",
    "items_count": 1,
    "items": [
      {
        "item_id": 108,
        "item_name": "Samsung Display 120Hz",
        "quantity": 10.0,
        "unit": "PCS",
        "rate": 1250.00,
        "tax_rate": 0.0,
        "tax_amount": 0.0,
        "total": 12500.00
      }
    ]
  }
}
```

#### 3. `payment_in.created` (Customer Receipt)
```json
{
  "id": "evt_01j7xyz947a1b2c3d4e5f6g7h8",
  "event": "payment_in.created",
  "api_version": "v1",
  "timestamp": "2026-09-26T11:45:00+06:00",
  "company": { "id": 1, "name": "Rabi Mobile", "phone": "9064313651" },
  "entity_type": "payment_in",
  "data": {
    "id": 503,
    "transaction_type": "payment_in",
    "document_number": "REC-503",
    "date": "2026-09-26",
    "party": {
      "id": 15,
      "name": "John Doe",
      "phone": "9876543210"
    },
    "total_amount": 770.00,
    "paid_amount": 770.00,
    "balance_amount": 0.00,
    "payment_mode": "UPI / GPay",
    "reference_no": "UPI-REF-99281"
  }
}
```

#### 4. `payment_out.created` (Supplier Payment)
```json
{
  "id": "evt_01j7xyz947a1b2c3d4e5f6g7h8",
  "event": "payment_out.created",
  "api_version": "v1",
  "timestamp": "2026-09-26T11:45:00+06:00",
  "company": { "id": 1, "name": "Rabi Mobile", "phone": "9064313651" },
  "entity_type": "payment_out",
  "data": {
    "id": 304,
    "transaction_type": "payment_out",
    "document_number": "VOU-304",
    "date": "2026-09-26",
    "party": {
      "id": 22,
      "name": "Supreme Mobile Wholesale Ltd",
      "phone": "9832100000"
    },
    "total_amount": 5000.00,
    "payment_mode": "Net Banking",
    "reference_no": "NEFT-883921"
  }
}
```

#### 5. `sale_return.created` (Credit Note)
```json
{
  "id": "evt_01j7xyz947a1b2c3d4e5f6g7h8",
  "event": "sale_return.created",
  "api_version": "v1",
  "timestamp": "2026-09-26T11:45:00+06:00",
  "company": { "id": 1, "name": "Rabi Mobile", "phone": "9064313651" },
  "entity_type": "sale_return",
  "data": {
    "id": 112,
    "transaction_type": "sale_return",
    "document_number": "RET-112",
    "date": "2026-09-26",
    "party": { "id": 15, "name": "John Doe", "phone": "9876543210" },
    "total_amount": 500.00,
    "reason": "Defective accessory replaced"
  }
}
```

---

### 👥 Parties & CRM

#### 6. `party.created`
```json
{
  "id": "evt_01j7xyz947a1b2c3d4e5f6g7h8",
  "event": "party.created",
  "api_version": "v1",
  "timestamp": "2026-09-26T11:45:00+06:00",
  "company": { "id": 1, "name": "Rabi Mobile", "phone": "9064313651" },
  "entity_type": "party",
  "data": {
    "id": 35,
    "name": "Acme Corporation Ltd",
    "phone": "9876543210",
    "email": "billing@acme.com",
    "category": "Customer",
    "gstin": "19AAACB1234F1Z6",
    "billing_address": "Station Road, Siliguri",
    "balance": 0.00,
    "created_at": "2026-09-26T11:45:00+06:00"
  }
}
```

#### 7. `party.updated` (With Changes Diff)
```json
{
  "id": "evt_01j7xyz947a1b2c3d4e5f6g7h8",
  "event": "party.updated",
  "api_version": "v1",
  "timestamp": "2026-09-26T11:45:00+06:00",
  "company": { "id": 1, "name": "Rabi Mobile", "phone": "9064313651" },
  "entity_type": "party",
  "data": {
    "id": 35,
    "name": "Acme Corporation Ltd",
    "phone": "9064313651",
    "email": "accounts@acme.com",
    "category": "Customer",
    "balance": 2500.00,
    "updated_at": "2026-09-26T11:45:00+06:00"
  },
  "changes": {
    "balance": { "previous": 0.00, "current": 2500.00 },
    "phone": { "previous": "9876543210", "current": "9064313651" },
    "email": { "previous": "billing@acme.com", "current": "accounts@acme.com" }
  }
}
```

#### 8. `party.deleted`
```json
{
  "id": "evt_01j7xyz947a1b2c3d4e5f6g7h8",
  "event": "party.deleted",
  "api_version": "v1",
  "timestamp": "2026-09-26T11:45:00+06:00",
  "company": { "id": 1, "name": "Rabi Mobile", "phone": "9064313651" },
  "entity_type": "party",
  "data": {
    "id": 35,
    "name": "Acme Corporation Ltd",
    "phone": "9064313651",
    "deleted_at": "2026-09-26T11:45:00+06:00"
  }
}
```

---

### 📦 Items & Inventory

#### 9. `item.created`
```json
{
  "id": "evt_01j7xyz947a1b2c3d4e5f6g7h8",
  "event": "item.created",
  "api_version": "v1",
  "timestamp": "2026-09-26T11:45:00+06:00",
  "company": { "id": 1, "name": "Rabi Mobile", "phone": "9064313651" },
  "entity_type": "item",
  "data": {
    "id": 108,
    "name": "Samsung Galaxy AMOLED Display",
    "item_code": "DISP-SAM-120",
    "hsn_code": "85177090",
    "category": "Spare Parts",
    "sale_price": 2200.00,
    "purchase_price": 1500.00,
    "stock_quantity": 20.0,
    "unit": "PCS",
    "tax_rate": 18.0,
    "is_product": true,
    "created_at": "2026-09-26T11:45:00+06:00"
  }
}
```

#### 10. `item.updated` (With Changes Diff)
```json
{
  "id": "evt_01j7xyz947a1b2c3d4e5f6g7h8",
  "event": "item.updated",
  "api_version": "v1",
  "timestamp": "2026-09-26T11:45:00+06:00",
  "company": { "id": 1, "name": "Rabi Mobile", "phone": "9064313651" },
  "entity_type": "item",
  "data": {
    "id": 108,
    "name": "Samsung Galaxy AMOLED Display",
    "sale_price": 2400.00,
    "stock_quantity": 20.0,
    "updated_at": "2026-09-26T11:45:00+06:00"
  },
  "changes": {
    "sale_price": { "previous": 2200.00, "current": 2400.00 }
  }
}
```

#### 11. `item.deleted`
```json
{
  "id": "evt_01j7xyz947a1b2c3d4e5f6g7h8",
  "event": "item.deleted",
  "api_version": "v1",
  "timestamp": "2026-09-26T11:45:00+06:00",
  "company": { "id": 1, "name": "Rabi Mobile", "phone": "9064313651" },
  "entity_type": "item",
  "data": {
    "id": 108,
    "name": "Samsung Galaxy AMOLED Display",
    "deleted_at": "2026-09-26T11:45:00+06:00"
  }
}
```

#### 12. `item.stock_adjusted` (ADD / REDUCE)
```json
{
  "id": "evt_01j7xyz947a1b2c3d4e5f6g7h8",
  "event": "item.stock_adjusted",
  "api_version": "v1",
  "timestamp": "2026-09-26T11:45:00+06:00",
  "company": { "id": 1, "name": "Rabi Mobile", "phone": "9064313651" },
  "entity_type": "item",
  "data": {
    "adjustment_id": 55,
    "item_id": 108,
    "item_name": "Samsung Galaxy AMOLED Display",
    "type": "ADD",
    "quantity": 10.0,
    "previous_stock": 20.0,
    "new_stock": 30.0,
    "date": "2026-09-26",
    "price": 1500.00,
    "details": "New shipment batch received"
  }
}
```

---

## 6. Drop-In Verification Snippets

### Python (FastAPI / Flask)
```python
import hmac
import hashlib
import time
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
WEBHOOK_SECRET = "your_64_char_webhook_secret_key" # Required

@app.post("/webhooks/hasabi")
async def verify_and_handle(request: Request):
    raw_body = await request.body()
    signature_header = request.headers.get("X-Hasabi-Signature")
    timestamp = request.headers.get("X-Hasabi-Timestamp")

    if not signature_header or not timestamp:
        raise HTTPException(status_code=401, detail="Missing signature headers")

    if abs(time.time() - int(timestamp)) > 300:
        raise HTTPException(status_code=403, detail="Timestamp expired (> 300s)")

    parts = dict(item.split("=") for item in signature_header.split(","))
    received_hash = parts.get("v1")

    signed_payload = f"{timestamp}.".encode("utf-8") + raw_body
    expected_hash = hmac.new(WEBHOOK_SECRET.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected_hash, received_hash):
        raise HTTPException(status_code=401, detail="Invalid HMAC signature")

    payload = await request.json()
    print(f"Verified event {payload['event']} (API Version: {payload['api_version']})")
    return {"status": "ok"}
```

### Node.js (Express)
```javascript
const express = require('express');
const crypto = require('crypto');

const app = express();
const WEBHOOK_SECRET = 'your_64_char_webhook_secret_key'; // Required

app.post('/webhooks/hasabi', express.raw({ type: 'application/json' }), (req, res) => {
    const rawBody = req.body;
    const sigHeader = req.headers['x-hasabi-signature'];
    const timestamp = req.headers['x-hasabi-timestamp'];

    if (!sigHeader || !timestamp) {
        return res.status(401).json({ error: 'Missing signature headers' });
    }

    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
        return res.status(403).json({ error: 'Timestamp expired' });
    }

    const parts = Object.fromEntries(sigHeader.split(',').map(pair => pair.split('=')));
    const receivedHash = parts.v1;

    const signedPayload = Buffer.concat([Buffer.from(`${timestamp}.`, 'utf-8'), rawBody]);
    const expectedHash = crypto.createHmac('sha256', WEBHOOK_SECRET).update(signedPayload).digest('hex');

    if (!crypto.timingSafeEqual(Buffer.from(expectedHash, 'hex'), Buffer.from(receivedHash, 'hex'))) {
        return res.status(401).json({ error: 'Invalid signature' });
    }

    const payload = JSON.parse(rawBody.toString('utf-8'));
    console.log(`Verified event ${payload.event}`);
    res.status(200).json({ status: 'ok' });
});
```
