//Comment API gateway and server for LiquiFact, the global invoice liquidity network on Stellar. This repo provides the Express-based REST API for invoice uploads, escrow state, and future Stellar integration.
Part of the LiquiFact stack: frontend (Next.js) | backend (this repo) | contracts (Soroban).
- Node.js 20+ (LTS recommended)
- npm 9+
- Docker & Docker Compose (for local PostgreSQL)
-
Clone the repo
git clone <this-repo-url> cd liquifact-backend
-
Install dependencies
npm install
-
Configure environment
cp .env.example .env # Edit .env with your database configuration -
Start database services
docker-compose -f docker-compose.dev.yml up -d
-
Run database migrations
npm run db:migrate
Optional Sentry error tracking is supported through the SENTRY_DSN environment variable. When enabled, the server scrubs sensitive values before sending events, including:
- Invoice payload bodies and invoice-related fields
- Authorization headers and bearer tokens
- API keys and secret values
- Stellar XDR / Stellar-specific payloads
Environment variables:
SENTRY_DSN— Optional Sentry DSN. Example:https://<PUBLIC_KEY>@o<ORG_ID>.ingest.sentry.io/<PROJECT_ID>SENTRY_RELEASE— Optional release tag. Defaults to package version when available.SENTRY_ENVIRONMENT— Optional environment tag. Defaults toNODE_ENV.
Do not store secrets in source control. Use .env locally and deployment secrets in production.
The API enforces a strict matching between STELLAR_NETWORK and SOROBAN_RPC_URL at boot time. This prevents misconfiguration where a passphrase (network identity) is paired with an incompatible RPC endpoint, which would cause on-chain validation failures.
| Network | Passphrase | RPC URL |
|---|---|---|
| TESTNET | Test SDF Network ; September 2015 |
https://soroban-testnet.stellar.org |
| MAINNET | Public Global Stellar Network ; September 2014 |
https://soroban.stellar.org |
| FUTURENET | Test SDF Future Network ; October 2022 |
https://rpc-futurenet.stellar.org |
Set both variables in your .env:
STELLAR_NETWORK=TESTNET
SOROBAN_RPC_URL=https://soroban-testnet.stellar.orgDo NOT use custom RPC URLs. The validation will reject any deviation from the expected RPC for the selected network.
On startup, src/index.js calls validateStellarConfig() from src/config/stellar.js. If the network/RPC combination is invalid, the server fails to start with a clear error message:
Error: Mismatch: STELLAR_NETWORK=TESTNET requires SOROBAN_RPC_URL="https://soroban-testnet.stellar.org", but got "https://custom-rpc.example.com". This combination would cause on-chain validation failures.
- The validation is a hard fail - no partial or degraded operation is permitted.
- This ensures the backend never signs transactions with a mismatched network, which could result in fund loss.
- The passphrase is derived from the network constant and is not user-configurable.
| Command | Description |
|---|---|
npm run dev |
Start API with watch mode |
npm run dev:ts |
Start API with TS runtime (optional) |
npm run start |
Start API |
npm run typecheck |
Run TypeScript type checking (no emit) |
npm run build |
Compile src/ to dist/ |
npm run start:dist |
Start compiled output from dist/ |
npm run lint |
Run ESLint on src/ |
npm test |
Run load helper tests and structured error tests |
npm run db:migrate |
Run database migrations |
npm run db:rollback |
Rollback last migration |
npm run db:seed |
Run database seeds |
npm run db:migrate:down |
Rollback last migration |
npm run db:migrate:create <name> |
Create new migration file |
npm run db:migrate:reset |
Reset database (drop & re-run) |
npm run test:coverage |
Run helper/API tests with coverage |
npm run load:baseline |
Run the core endpoint load baseline suite |
Default port: 3001.
Escrow Redis cache is optional and disabled by default; set REDIS_ESCROW_CACHE_ENABLED=true with REDIS_URL to enable it.
REDIS_ESCROW_CACHE_TTL_SECONDS is strictly clamped to 5..300, and REDIS_ESCROW_LEDGER_GAP_THRESHOLD controls ledger-gap invalidation.
Incremental TypeScript setup and migration guidance lives in docs/typescript-plan.md.
This project uses node-pg-migrate for database schema management with PostgreSQL. The migration system provides:
- SQL-first migration control with rollback support
- Multi-tenant architecture with Row Level Security (RLS)
- Production-safe transaction handling
- Comprehensive audit logging
# Start PostgreSQL and Redis
docker-compose -f docker-compose.dev.yml up -d
# Run migrations
npm run db:migrate- Multi-tenant isolation with tenant-scoped data
- Soft deletes for data recovery
- Audit trail for compliance
- UUID primary keys for distributed systems
- JSONB metadata for schema flexibility
📖 Full documentation: See DB_MIGRATIONS.md for comprehensive migration guide, troubleshooting, and deployment procedures.
The API is documented using OpenAPI 3.0 specification.
- OpenAPI JSON:
GET /openapi.json- Machine-readable API specification - Interactive Docs:
GET /docs- Swagger UI for exploring and testing the API - Correlation Strategy: See
docs/invoice-correlation.mdfor details on howinvoiceIdcorrelates with on-chain Stellar and Soroban data.
The documentation covers all public endpoints including health checks, invoice management, escrow operations, and investment opportunities.
- Marketplace:
GET /api/marketplace- Search and sort invoices by yield, maturity, and funded ratio. Supports advanced filtering (yieldBpsMin,maturityDateTo,fundedRatioMin, etc.) and pagination.
Example:
curl -H "Authorization: Bearer <token>" \
"http://localhost:3001/api/marketplace?yieldBpsMin=500&sortBy=yield_bps&order=desc"LiquiFact uses tenant-scoped object storage and strict validation controls for invoice uploads.
Invoice files are stored using tenant and invoice scoped object keys:
tenants/{tenantId}/invoices/{invoiceId}/{uuid}-{filename}
Example:
tenants/tenant-123/invoices/inv-456/550e8400-e29b-41d4-a716-446655440000-invoice.pdf
Security benefits:
- Tenant isolation
- Invoice isolation
- UUID-based object naming
- Protection against object enumeration
- Prevention of cross-tenant object access
Uploaded filenames are sanitized before storage.
The storage layer:
- Rejects path traversal attempts (
../) - Rejects invalid filenames
- Removes null bytes
- Sanitizes special characters
- Truncates excessively long filenames
Examples:
../../etc/passwd -> rejected
..\..\windows\system32 -> rejected
invoice.pdf -> accepted
Tenant IDs and invoice IDs are validated before key generation.
Allowed characters:
a-z
A-Z
0-9
_
-
Rejected examples:
../../admin
tenant/admin
inv/123
Supported invoice file types:
- application/pdf
- image/jpeg
- image/png
- image/tiff
Validation occurs during:
- Direct uploads
- Presigned URL generation
Unsupported MIME types are rejected before any storage operation occurs.
Invoice uploads are limited by:
BODY_LIMIT_INVOICEDefault:
512 KB
Files exceeding the configured limit are rejected.
Upload URLs:
15 minutes
Download URLs:
Default: 1 hour
Maximum: 24 hours
Requests outside the allowed expiry range are rejected.
The invoice upload subsystem includes:
- Path traversal protection
- MIME type allow-listing
- File size enforcement
- Tenant isolation
- Invoice isolation
- UUID object naming
- Presigned URL expiry limits
- AWS credential non-disclosure
- Server-side validation before S3 operations
Core routes currently covered:
- Health:
GET /health - API Info:
GET /api - Invoices:
GET /api/invoices(with optional status filter),GET /api/invoices/:id,POST /api/invoices - Escrow:
GET /api/escrow/:invoiceId,POST /api/escrow - Investment:
GET /api/invest/opportunities - SME Metrics:
GET /api/sme/metrics
liquifact-backend/
├── src/
│ └── index.js
├── tests/
│ └── load/
│ ├── config.js
│ ├── reporter.js
│ ├── run-baselines.js
│ └── *.test.js
├── .env.example
├── eslint.config.js
└── package.json
For the full end-to-end model (indexer → projection → GET /api/escrow, funding via escrowSubmit, reconciliation, signing modes, and env contracts), see docs/escrow-integration-overview.md.
The API supports invoice-to-escrow contract address resolution using environment-based configuration for early phases. This allows mapping invoice IDs to their corresponding Stellar escrow contract addresses without requiring on-chain registry lookups.
Configure escrow mappings using the ESCROW_ADDR_BY_INVOICE environment variable:
ESCROW_ADDR_BY_INVOICE='{"mappings":[{"invoiceId":"inv_demo_001","escrowAddress":"GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLM","environment":"development","isActive":true}],"defaultEnvironment":"development","allowlistEnabled":true,"cacheEnabled":true,"cacheTtlSeconds":300}'- Allowlist Validation: Only mapped invoices can be resolved
- Environment Separation: Different mappings for development, staging, production
- Address Validation: Ensures Stellar addresses are properly formatted
- Caching: In-memory caching with configurable TTL
- Input Validation: Strict validation of invoice IDs and addresses
The mapping system is automatically used by escrow endpoints. When resolving /api/escrow/:invoiceId, the system:
- Validates the invoice ID format
- Checks if the invoice is in the allowlist for the current environment
- Returns the corresponding Stellar escrow contract address
- Caches the result for subsequent requests
For production deployments:
- Environment Separation: Use different mappings per environment
- Key Rotation: Update mappings by modifying the environment variable
- Monitoring: Use health checks to validate mapping configuration
- Security: Only map invoices you own or have explicit permission to map
{
"mappings": [
{
"invoiceId": "inv_123",
"escrowAddress": "GABC...123",
"environment": "development",
"isActive": true
}
],
"defaultEnvironment": "development",
"allowlistEnabled": true,
"cacheEnabled": true,
"cacheTtlSeconds": 300
}The repo includes a focused load baseline suite for representative core endpoint reads:
GET /healthGET /api/invoicesGET /api/escrow/:invoiceId
The suite uses autocannon and captures:
- total requests
- throughput in requests per second
- average latency
- p50 latency
- p95 latency
- p99 latency
- error count
- non-2xx count
- timeout count
These are the canonical health, invoices, and escrow endpoints currently exposed by the backend. They provide a low-risk baseline for throughput and latency without introducing destructive writes.
The load suite is intentionally safe by default:
- it targets
http://127.0.0.1:3001 - it blocks remote targets unless
ALLOW_REMOTE_LOAD_BASELINES=true - it does not hardcode tokens or credentials
- it uses a placeholder escrow invoice id unless a fixture id is provided
Do not run the suite against production without explicit approval.
| Variable | Default | Purpose |
|---|---|---|
LOAD_BASE_URL |
http://127.0.0.1:3001 |
Base URL for the load target |
ALLOW_REMOTE_LOAD_BASELINES |
false |
Explicit opt-in for non-local targets |
LOAD_DURATION_SECONDS |
15 |
Duration per endpoint scenario |
LOAD_CONNECTIONS |
10 |
Concurrent connections per scenario |
LOAD_TIMEOUT_SECONDS |
10 |
Request timeout |
LOAD_AUTH_TOKEN |
unset | Optional bearer token for protected endpoints |
LOAD_ESCROW_INVOICE_ID |
placeholder-invoice |
Escrow fixture id |
LOAD_REPORT_DIR |
tests/load/reports |
Directory for generated reports |
-
Start the API locally:
npm run dev
-
In another terminal, run the baseline suite:
npm run load:baseline
-
Optional example with custom settings:
LOAD_DURATION_SECONDS=20 LOAD_CONNECTIONS=25 LOAD_ESCROW_INVOICE_ID=invoice-123 npm run load:baseline
The repository includes a reproducible one-command E2E smoke test script that uses Docker Compose to spin up a fully isolated environment including the API, a test Postgres database, and a mocked Soroban RPC server.
- Service health:
/health(verifies API, DB reachability, and Soroban mock integration). - Versioned API:
GET /v1/escrow/:invoiceId(verifies token authentication and Soroban mock state). - Backward compatibility:
GET /api/escrow/:invoiceId(verifies deprecation warning headers).
Ensure you have Docker and Docker Compose installed.
npm run e2e:apiThe script will:
- Build and start the
api,db, andmock-sorobanservices. - Wait for the API to report a healthy status.
- Run the Jest smoke test suite against the live containers.
- Clean up (shutdown and remove) the containers and volumes.
- Isolated Environment: Uses a dedicated
docker-compose.e2e.ymland a private network. - Mocked Dependencies: Points
SOROBAN_RPC_URLto a local mock server to ensure tests are fast, deterministic, and don't require external network access. - Fail-Fast Healthchecks: The API and DB services use Docker healthchecks to ensure dependent services only start when their dependencies are ready.
Each run generates:
- a JSON artifact
- a Markdown artifact
- a console summary
By default, reports are written to:
tests/load/reports/
│ ├── config/ │ │ └── cors.js # CORS allowlist parsing and policy │ ├── middleware/ │ │ ├── auth.js # JWT authentication middleware │ │ ├── audit.js # Immutable audit logging for mutations │ │ ├── deprecation.js # API deprecation notices │ │ ├── errorHandler.js # Centralized error handling │ │ ├── rateLimit.js # Rate limiting enforcement │ │ └── stacks.js # Composed middleware stacks (authenticatedTenantStack, adminStack) │ ├── services/ │ │ ├── invoiceService.js # Business logic and pagination │ │ └── soroban.js # Contract interaction wrappers │ ├── utils/ │ │ ├── asyncHandler.js # Express async error wrapper │ │ └── retry.js # Exponential backoff utility │ ├── app.js # Express app, middleware, routes │ └── index.js # Runtime bootstrap ├── tests/ │ ├── setup.js # Test configuration │ ├── helpers/ │ │ └── createTestApp.js # Test app factory │ ├── unit/ │ │ ├── asyncHandler.test.js │ │ └── errorHandler.test.js │ └── app.test.js ├── .env.example # Env template ├── eslint.config.js └── package.json
---
## Resiliency & Retries
### Security notes
- Remote load targets are blocked by default.
- Secrets and tokens must come from environment variables.
- The suite never prints auth tokens.
- If protected endpoints are added later, use least-privilege non-production credentials.
- The selected baseline endpoints are low-risk reads to avoid destructive behavior.
### Edge cases handled
- missing base URL falls back to a safe local default
- remote targets require explicit opt-in
- invalid concurrency, duration, or timeout values are rejected
- missing auth token is handled gracefully
- missing escrow fixture id falls back to a placeholder
- partial endpoint failures are still captured in the report
### Limitations
- This suite establishes baselines, not maximum capacity.
- Results depend on local machine resources and runtime conditions.
- The invoices and escrow endpoints are currently placeholders, so these baselines should be treated as early reference points rather than production sizing data.
---
## Structured API errors
All API failures now return a consistent structured error payload:
```json
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Malformed JSON request body.",
"correlation_id": "req_f7d1b9f6c0f1459d8b3b7b6a",
"retryable": false,
"retry_hint": "Fix the JSON payload and try again."
}
}
code: stable machine-readable error codemessage: safe human-readable messagecorrelation_id: per-request identifier for debugging and supportretryable: whether the caller may safely retryretry_hint: safe retry guidance
VALIDATION_ERRORAUTHENTICATION_REQUIREDFORBIDDENNOT_FOUNDUPSTREAM_ERRORINTERNAL_SERVER_ERROR
- Every request receives a correlation ID.
- The API returns it in both the response body and the
X-Correlation-Idheader. - If a client sends
X-Correlation-Idand it matches the accepted pattern, the value is echoed back. - Invalid client-supplied IDs are ignored and replaced with a generated ID.
The centralized mapper covers:
- malformed JSON
- validation failures
- authorization and authentication failures
- not found responses
- upstream connection failures
- unexpected thrown errors
- non-
Errorthrown values
- Internal stack traces and raw exception details are never returned to clients.
- Correlation IDs are sanitized and do not expose internal state.
- Retry hints are generic and do not leak infrastructure details.
- Server-side logs include correlation context without returning sensitive internals in responses.
Validation error:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invoice payload must be a JSON object.",
"correlation_id": "req_d3b92b4d2d554f33b8d8b089",
"retryable": false,
"retry_hint": "Send a valid JSON object in the request body and try again."
}
}Unexpected error:
{
"error": {
"code": "INTERNAL_SERVER_ERROR",
"message": "An internal server error occurred.",
"correlation_id": "req_3d5d8c9e4ff34dd9aa73b946",
"retryable": false,
"retry_hint": "Do not retry until the issue is resolved or support is contacted."
}
}The backend supports durable idempotency keys for funding operations to safely retry requests without risking double-funding.
- Send an
Idempotency-Keyheader with each distinct funding request. The key must be an 8-128 character URL-safe string. - First use: The backend processes the request and persists the key along with a SHA-256 hash of the payload and the resulting response.
- Identical retries: Resending the same key with the same payload will short-circuit and instantly replay the cached response.
- Conflicting payload: Resending the same key with a different payload body results in a
409 Conflictcontaining an RFC 7807application/problem+jsonerror envelope.
- Keys expire after a configurable TTL (default is 24 hours, overridable via
IDEMPOTENCY_KEY_TTL_HOURS). - Expired keys are automatically purged to save database space, governed by the
expires_atindex.
The backend now supports a database-backed append-only audit log for:
- admin actions (for example, KYC state transitions or key-rotation operations)
- webhook dispatch outcomes (success/failure with redacted payload fields)
Run SQL migrations in order:
migrations/202604260001_create_audit_log_events.sqlmigrations/202604260002_enforce_audit_log_append_only.sql
audit_log_events is enforced as append-only at the database layer via triggers that reject UPDATE and DELETE.
src/middleware/auditLog.jsattachesreq.audithelpers:req.audit.logAdminAction(...)req.audit.logWebhookDelivery(...)
- successful
POST|PUT|PATCH|DELETErequests under/api/admin/*are auto-logged - sensitive fields are redacted before persistence (
password,token,secret,apiKey,privateKey, etc.)
GET /api/admin/audit/invoices/:invoiceId/export accepts a format query parameter:
format |
Behaviour |
|---|---|
json (default) |
Returns a paginated JSON array. The limit query param (default 50, max 500) controls the page size. |
csv |
Streaming: rows are emitted directly from the database cursor and piped to the HTTP response. The full result set is never buffered in memory, making this safe for arbitrarily large audit trails. |
PostgreSQL cursor (Knex .stream())
→ createCsvTransform() ← object-mode Transform, writes header on first row
→ res (HTTP response)
Both ends of the pipeline attach error listeners. If the database stream or the transform errors after headers have been flushed, the socket is destroyed cleanly to avoid a hanging client connection.
Every CSV field is processed by escapeCsvField() in src/services/auditLogStore.js:
- Leading-character neutralisation — cells beginning with
=,+,-,@, TAB, or CR are prefixed with a single quote ('). This prevents spreadsheet software (Excel, LibreOffice Calc, Google Sheets) from interpreting the cell as a formula or a DDE command. - RFC 4180 quoting — fields containing commas, double-quotes, or newlines are wrapped in double-quotes; embedded double-quotes are doubled (
"→"").
Tenant scoping is enforced at the database level using a whereRaw filter on the JSONB metadata column:
WHERE metadata->>'tenantId' = ?No cross-tenant row is ever loaded into application memory.
Content-Type: text/csv
Content-Disposition: attachment; filename="audit-<invoiceId>.csv"
id, timestamp, actor, action, resourceType, resourceId, statusCode, ipAddress, userAgent
Admin action logging:
curl -X POST http://localhost:3001/api/admin/kyc/cus_42/approve \
-H "Authorization: Bearer <admin-jwt>" \
-H "x-admin-action: kyc.approve" \
-H "x-audit-target-type: kyc_profile" \
-H "x-audit-target-id: cus_42" \
-H "Content-Type: application/json" \
-d '{"reason":"manual review","privateKey":"redacted-at-write-time"}'Streaming CSV export:
curl -H "Authorization: Bearer <admin-jwt>" \
-H "x-tenant-id: tenant-alpha" \
"http://localhost:3001/api/admin/audit/invoices/inv-001/export?format=csv" \
-o audit-inv-001.csvJSON export (paginated):
curl -H "Authorization: Bearer <admin-jwt>" \
-H "x-tenant-id: tenant-alpha" \
"http://localhost:3001/api/admin/audit/invoices/inv-001/export?format=json&limit=100"The repo includes a focused load baseline suite for representative core endpoint reads:
GET /healthGET /api/invoicesGET /api/escrow/:invoiceId
The suite uses autocannon and captures:
- total requests
- throughput in requests per second
- average latency
- p50 latency
- p95 latency
- p99 latency
- error count
- non-2xx count
- timeout count
- targets
http://127.0.0.1:3001 - blocks remote targets unless
ALLOW_REMOTE_LOAD_BASELINES=true - does not hardcode tokens or credentials
- uses a placeholder escrow invoice id unless a fixture id is provided
Do not run the suite against production without explicit approval.
| Variable | Default | Purpose |
|---|---|---|
LOAD_BASE_URL |
http://127.0.0.1:3001 |
Base URL for the load target |
ALLOW_REMOTE_LOAD_BASELINES |
false |
Explicit opt-in for non-local targets |
LOAD_DURATION_SECONDS |
15 |
Duration per endpoint scenario |
LOAD_CONNECTIONS |
10 |
Concurrent connections per scenario |
LOAD_TIMEOUT_SECONDS |
10 |
Request timeout |
LOAD_AUTH_TOKEN |
unset | Optional bearer token for protected endpoints |
LOAD_ESCROW_INVOICE_ID |
placeholder-invoice |
Escrow fixture id |
LOAD_REPORT_DIR |
tests/load/reports |
Directory for generated reports |
npm run dev
npm run load:baseline- Remote load targets are blocked by default.
- Secrets and tokens must come from environment variables.
- The suite never prints auth tokens.
- The selected baseline endpoints are low-risk reads.
All API failures return a structured error payload:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Malformed JSON request body.",
"correlation_id": "req_f7d1b9f6c0f1459d8b3b7b6a",
"retryable": false,
"retry_hint": "Fix the JSON payload and try again."
}
}VALIDATION_ERRORAUTHENTICATION_REQUIREDINVALID_TOKENFORBIDDENNOT_FOUNDRATE_LIMITEDUPSTREAM_ERRORINTERNAL_SERVER_ERROR
- Internal stack traces and raw exception details are never returned to clients.
- Correlation IDs are sanitized.
- Retry hints are generic and do not leak infrastructure details.
The repo includes a focused negative security test suite for middleware hardening.
- unauthorized requests with no
Authorizationheader - malformed
Authorizationheader formats - invalid or tampered Bearer tokens
- rate-limited abuse against a representative protected endpoint
- non-leakage checks for error bodies and headers
- public-route behavior when malformed auth headers are present
GitHub Actions runs on push and pull requests to main:
- Lint:
npm run lint - Build check:
node --check src/index.js
- Fork the repo and clone your fork.
- Create a branch from
main. - Run
npm install. - Make focused changes and keep style consistent.
- Run
npm run lint,npm test, and any relevant local checks. - Push your branch and open a pull request.
We welcome docs improvements, bug fixes, and new API endpoints aligned with LiquiFact product goals.
See CONTRIBUTING.md for branch naming, local checks, testing expectations, CI behavior, and pull request guidance.
LiquiFact delivers signed webhook callbacks to tenant-configured endpoints whenever an invoice transitions between states (e.g. pending → approved, approved → linked_escrow).
- State transition —
invoiceStateMachine.executeTransitioncompletes successfully. - Job enqueue —
enqueueWebhookDeliverylooks up the tenant'swebhook_url/webhook_secretfrom the database and enqueues awebhook_deliveryjob via the sharedBackgroundWorker. - Signed delivery — the
webhookDeliveryjob handler constructs a deterministically-sorted JSON payload, signs it with HMAC-SHA256 (v1scheme), and POSTs it with anX-Signatureheader. - Retry — transient failures (network errors, HTTP 5xx) are retried with bounded exponential backoff. Non-retriable failures (HTTP 4xx) are not retried.
- Dead-letter — after exhausting all retry attempts the delivery is written to
webhook_dead_lettersand a Prometheus counter is incremented.
Every webhook request carries an X-Signature header in the format:
t=<unix_timestamp>,v1=<hmac_sha256_hex>
To verify on the receiving end:
- Extract
t(timestamp, seconds since epoch) andv1(hex signature) from the header. - Reject if
|now_ms − t × 1000| > 300000(5-minute tolerance window). - Compute the expected signature:
HMAC-SHA256(secret, "<t>.<raw_body>") - Compare using a constant-time function (e.g.
crypto.timingSafeEqual) to prevent timing attacks. - Reject if the signatures do not match.
Example (Node.js receiver):
const crypto = require('crypto');
function verifyWebhook(secret, rawBody, signatureHeader) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('='))
);
const ts = parseInt(parts.t, 10);
if (Math.abs(Date.now() - ts * 1000) > 5 * 60 * 1000) {
return false; // replay / clock-skew rejected
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(parts.v1, 'hex'),
Buffer.from(expected, 'hex')
);
}Keys are always sorted alphabetically (deterministic) to simplify signature verification on any platform.
| Variable | Default | Description |
|---|---|---|
WEBHOOK_MAX_RETRIES |
3 |
Max retry attempts after the first failure |
WEBHOOK_BASE_DELAY |
500 |
Base exponential-backoff delay (ms) |
WEBHOOK_MAX_DELAY |
10000 |
Maximum backoff delay cap (ms) |
WEBHOOK_TIMEOUT_MS |
5000 |
Per-request HTTP timeout (ms) |
Configure per-tenant webhook delivery by storing webhook_url and webhook_secret in the tenants.settings JSONB column:
UPDATE tenants
SET settings = settings || '{"webhook_url":"https://your.endpoint/cb","webhook_secret":"<strong-random-secret>"}'
WHERE id = 'your-tenant-id';Security: Generate
webhook_secretwith at least 32 bytes of cryptographic randomness (e.g.openssl rand -hex 32). Rotate secrets by updating the column — in-flight jobs will fail safe and dead-letter, then delivery resumes automatically on the next enqueue.
- Secrets and full target URLs are never logged at
infolevel. - Signature comparison uses
crypto.timingSafeEqual— no timing side-channels. - The 5-minute timestamp tolerance prevents replay attacks.
- Webhook delivery failures never affect the outcome of a state transition.
- Dead-lettered deliveries are stored in
webhook_dead_lettersfor ops inspection.
MIT (see root LiquiFact project for full license).
{ "event": "invoice.pending_to_approved", "invoiceId": "inv_abc123", "tenantId": "tenant_xyz", "timestamp": "2025-01-15T12:00:00.000Z", "transition": { "actor": "usr_admin", "from": "pending", "reason": null, "to": "approved", "transitionedAt": "2025-01-15T12:00:00.000Z" } }