# 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=...`. |
| **Verify Endpoint** | `GET /webhooks/verify` or `POST /webhooks/verify` | Open route to verify secret key, company scope, and subscribed channels. |


---

## 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. Credentials & Subscribed Channels Verification Endpoint (`/webhooks/verify`)

An open, unauthenticated verification route is available for client applications, automation tools, and AI agents to:
1. Verify the authenticity of their webhook signing secret key.
2. Retrieve the active status and company scope (`global_all_companies` or `single_company`).
3. Inspect the full list of subscribed event channels.
4. Verify if a specific event channel is subscribed to (including wildcard resolution like `transaction.*`).

### Endpoints:
- `GET /webhooks/verify?secret=...&channel=...`
- `POST /webhooks/verify` (JSON Body: `{"secret": "...", "channel": "..."}`)
- `GET /api/v1/webhooks/verify?secret=...&channel=...`
- `POST /api/v1/webhooks/verify`

> **Authentication:** None required. Authentication is performed directly via the provided `secret` parameter, JSON payload, or `X-Hasabi-Secret` header.

### Parameters:

| Parameter | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| `secret` | `string` | **Yes** | Webhook signing secret key. Can be supplied via query param, JSON body, or `X-Hasabi-Secret` header. |
| `channel` (or `event`) | `string` | No | Optional event channel name to check (e.g. `sale_invoice.created`, `party.updated`, `transaction.*`). |
| `webhook_id` | `integer` | No | Optional specific webhook endpoint ID. |
| `url` | `string` | No | Optional receiver endpoint URL. |

### Successful Response (`HTTP 200 OK`):
```json
{
  "valid": true,
  "message": "Webhook credentials and configuration verified successfully.",
  "webhook": {
    "id": 1,
    "name": "Production ERP Sync",
    "url": "https://api.example.com/webhooks/hasabi",
    "is_active": true,
    "status": "healthy",
    "company": {
      "id": 1,
      "name": "Hasabi Demo Retail Ltd"
    },
    "scope": "single_company",
    "subscribed_channels": [
      "transaction.*",
      "party.created"
    ],
    "total_subscribed_channels": 2,
    "last_triggered_at": "2026-09-30T07:15:00+06:00",
    "last_status_code": 200
  },
  "channel_check": {
    "channel": "sale_invoice.created",
    "is_subscribed": true,
    "configured_in_events": true,
    "status": "active",
    "reason": "Subscribed and active for this channel."
  },
  "timestamp": "2026-09-30T08:15:00+06:00"
}
```

### Error Responses:
- **`HTTP 400 Bad Request`**: Missing secret key.
  ```json
  {
    "valid": false,
    "message": "Signing secret key is required. Provide it via query param \"secret\", JSON body {\"secret\": \"...\"}, or \"X-Hasabi-Secret\" header.",
    "status_code": 400
  }
  ```
- **`HTTP 404 Not Found`**: Secret key does not match any registered webhook endpoint.
  ```json
  {
    "valid": false,
    "message": "No webhook found matching the provided secret key.",
    "status_code": 404
  }
  ```

---

## 5. 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", "sale_invoice.updated")
  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?: {               // Field diff dictionary for *.updated events
    [scalarField: string]: { previous: any; current: any } | any;
    items?: {               // Line-item diff engine (O(N+M) key-indexed)
      modified?: Array<{ item_id: number | null; item_name: string; changes: Record<string, { previous: any; current: any }> }>;
      added?: Array<Record<string, any>>;
      removed?: Array<Record<string, any>>;
    };
    payments?: {            // Payment split diff engine
      modified?: Array<{ id?: number | null; method: string; changes: Record<string, { previous: any; current: any }> }>;
      added?: Array<Record<string, any>>;
      removed?: Array<Record<string, any>>;
    };
  };
}
```

---

## 6. 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. `sale_invoice.updated` (With Line-Item & Split-Payment Diffs)
```json
{
  "id": "evt_01j7xyz947a1b2c3d4e5f6g7h8",
  "event": "sale_invoice.updated",
  "api_version": "v1",
  "timestamp": "2026-10-05T11:00: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"
    },
    "total_amount": 2500.00,
    "paid_amount": 1500.00,
    "balance_amount": 1000.00,
    "status": "partial",
    "payment_mode": "Cash",
    "items_count": 2
  },
  "changes": {
    "total_amount": {
      "previous": 1770.00,
      "current": 2500.00
    },
    "paid_amount": {
      "previous": 1000.00,
      "current": 1500.00
    },
    "balance_amount": {
      "previous": 770.00,
      "current": 1000.00
    },
    "items": {
      "modified": [
        {
          "item_id": 108,
          "item_name": "Samsung Display 120Hz",
          "changes": {
            "quantity": {
              "previous": 1.0,
              "current": 2.0
            },
            "tax_amount": {
              "previous": 270.0,
              "current": 540.0
            },
            "total": {
              "previous": 1770.0,
              "current": 3540.0
            }
          }
        }
      ],
      "added": [
        {
          "item_id": 115,
          "item_name": "Screen Protector",
          "quantity": 1.0,
          "unit": "PCS",
          "rate": 200.0,
          "tax_rate": 0.0,
          "tax_amount": 0.0,
          "total": 200.0
        }
      ],
      "removed": []
    },
    "payments": {
      "modified": [
        {
          "method": "Cash",
          "changes": {
            "amount": {
              "previous": 1000.0,
              "current": 500.0
            }
          }
        }
      ],
      "added": [
        {
          "method": "Bank Transfer",
          "amount": 1000.0,
          "reference": "UPI-8849201"
        }
      ],
      "removed": []
    }
  }
}
```

#### 4. `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"
  }
}
```

#### 5. `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"
  }
}
```

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

#### 7. `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"
  }
}
```

#### 8. `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" }
  }
}
```

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

#### 10. `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"
  }
}
```

#### 11. `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 }
  }
}
```

#### 12. `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"
  }
}
```

#### 13. `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"
  }
}
```

---

## 7. 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' });
});
```
