Webhooks
The Equa platform uses webhooks for both receiving third-party billing events and (proposed) sending outbound notifications when resources change. This page documents both systems.
Inbound Webhooks (Chargify)
The Equa API receives webhooks from Chargify (Maxio) to keep billing and subscription data in sync.
Source: equa-server/modules/api/src/endpoints/billing-endpoints.ts
Events Handled
| Event Type | Description |
|---|
| Subscription created | A new subscription is activated for an organization |
| Subscription cancelled | An organization’s subscription is cancelled |
| Payment received | A payment is successfully processed |
| Payment failed | A payment attempt fails |
| Member count updated | Shareholder/member count changes trigger plan adjustments |
How It Works
- An event occurs in Chargify (e.g., subscription state change)
- Chargify sends an HTTP POST to the configured webhook URL on the Equa API
- The billing module processes the event and updates the local database
- Subscription features and limits are recalculated for the affected organization
Subscription Synchronization
The billing system automatically syncs member/shareholder counts with Chargify whenever cap table changes occur. This ensures billing stays aligned with actual usage:
- When a shareholding is created or deleted, the shareholder count is updated via
appendShareholderCountUpdateViaShareholding (Source: captable-endpoints.ts, line 138)
- When a holding (option grant) is created or deleted, the member count is updated via
updateChargifyMemberCount (Source: captable-endpoints.ts, line 139)
- These count changes trigger Chargify component quantity updates
Webhook Security
Webhook payloads from Chargify are validated server-side to ensure they originate from Chargify. Only events from authenticated Chargify accounts tied to the configured Chargify API credentials are processed.
Configuring Chargify Webhooks
Webhook URLs are configured in the Chargify dashboard and correspond to the billing endpoints on the Equa API server. These are internal server-to-server integrations and are not exposed for external consumption.
If you need to integrate with Equa billing events in your application, use the Billing Endpoints to query subscription and transaction data instead.
Outbound Webhooks (Proposed)
The outbound webhook system is a proposed design specification. It is not yet
implemented in the Equa API. This documentation describes the planned architecture
for when the feature is built. All endpoints, payloads, and behaviors described in
this section are subject to change.
Overview
The proposed outbound webhook system allows integrators to receive real-time HTTP notifications when resources change within the Equa platform. The design follows the Stripe webhook model — a widely adopted pattern for financial SaaS APIs.
Key capabilities:
- Event-driven: Receive HTTP POST callbacks when cap table, organization, document, or billing events occur
- Secure: HMAC-SHA256 signatures on every payload for verification and replay protection
- Reliable: Automatic retries with exponential backoff over a 26-hour window
- Filterable: Subscribe to specific event types per webhook endpoint
- Idempotent: Unique event IDs enable safe deduplication by consumers
Event Types
Events use {resource}.{action} dot-notation. Subscribe to all events or filter by specific types.
Cap Table Events
These events fire when equity instruments and related records change within an organization’s cap table.
| Event | Trigger |
|---|
shareholding.created | A new shareholding is issued (Source: captable-endpoints.ts — POST /v1/organization/:organization/shareholding) |
shareholding.updated | A shareholding’s attributes are modified (Source: captable-endpoints.ts — PATCH or PUT /v1/shareholding/:holding) |
shareholding.voided | A shareholding is voided/cancelled (Source: captable-endpoints.ts — DELETE /v1/shareholding/:holding) |
shareholding.transferred | Shares are transferred between members (Source: captable-endpoints.ts — POST /v1/organization/:organization/shareholding/transfer) |
security.created | A new security type is defined (Source: captable-endpoints.ts — POST /v1/organization/:organization/security) |
security.updated | A security type’s attributes are modified (Source: captable-endpoints.ts — PUT /v1/organization/:organization/security/:security) |
security.deleted | A security type is removed (Source: captable-endpoints.ts — DELETE /v1/organization/:organization/security/:security) |
option.exercised | A stock option is exercised (Source: captable-endpoints.ts — POST /v1/plan/:plan/pool/:pool/option/:option/exercise) |
option.exercise_requested | An option exercise is requested (pending approval) (Source: captable-endpoints.ts — POST /v1/plan/:plan/pool/:pool/option/:option/exercise/request) |
holding.created | A new holding (e.g., option grant, convertible note) is created (Source: captable-endpoints.ts — POST /v1/holding) |
holding.updated | A holding’s attributes are modified (Source: captable-endpoints.ts — PUT or PATCH /v1/holding/:holding) |
holding.deleted | A holding is removed (Source: captable-endpoints.ts — DELETE /v1/holding/:holding) |
holding.converted | A convertible holding is converted to equity (Source: captable-endpoints.ts — POST /v1/holding/:holding/convert) |
holding.repaid | A holding is repaid (e.g., note repayment) (Source: captable-endpoints.ts — POST /v1/holding/:holding/repay) |
valuation.created | A new company valuation is recorded |
valuation.updated | A valuation is modified (Source: captable-endpoints.ts — PUT /v1/valuation) |
Organization Events
These events fire when organization-level resources change.
| Event | Trigger |
|---|
organization.created | A new organization is created (Source: organization-endpoints.ts — POST /v1/organization) |
organization.updated | An organization’s details are modified (Source: organization-endpoints.ts — PATCH /v1/organization/:organization) |
member.invited | One or more members are invited to an organization (Source: organization-endpoints.ts — POST /v1/organization/:organization/member/invite) |
member.added | A new member is added to an organization (Source: organization-endpoints.ts — POST /v1/organization/:organization/member) |
member.removed | A member is removed from an organization (Source: organization-endpoints.ts — DELETE /v1/organization/:organization/member/:member) |
member.updated | A member’s details or permissions are modified (Source: organization-endpoints.ts — PATCH /v1/organization/:organization/member/:member) |
role.created | A new role is created (Source: organization-endpoints.ts — POST /v1/organization/:organization/role) |
role.updated | A role’s permissions are modified (Source: organization-endpoints.ts — PUT /v1/organization/:organization/role/:role) |
role.deleted | A role is removed (Source: organization-endpoints.ts — DELETE /v1/organization/:organization/role/:role) |
Document Events
These events fire when documents and folders are managed within an organization.
| Event | Trigger |
|---|
document.uploaded | A document is uploaded to an organization (Source: organization-endpoints.ts — POST /v1/organization/:organization/file) |
document.moved | A document is moved between folders |
document.deleted | A document is deleted |
folder.created | A new folder is created within the document tree |
Subscription Events
These events fire for billing lifecycle changes. They complement the inbound Chargify webhooks by providing a normalized outbound notification format.
| Event | Trigger |
|---|
subscription.created | A new subscription is activated (Source: billing-endpoints.ts — POST /v1/organization/:organization/subscription) |
subscription.cancelled | A subscription is cancelled (Source: billing-endpoints.ts — DELETE /v1/organization/:organization/subscription/:subscription) |
payment.succeeded | A payment is successfully processed |
payment.failed | A payment attempt fails |
Event Type Summary
| Domain | Event Count | Events |
|---|
| Cap Table | 16 | shareholding.*, security.*, option.*, holding.*, valuation.* |
| Organization | 9 | organization.*, member.*, role.* |
| Documents | 4 | document.*, folder.* |
| Subscriptions | 4 | subscription.*, payment.* |
| Total | 33 | |
Every webhook delivery uses a consistent JSON envelope:
{
"id": "evt_2f8a3b1c4d5e6f7890abcdef",
"type": "shareholding.created",
"api_version": "2026-02-21",
"created": "2026-02-21T12:00:00Z",
"organization": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f9a",
"data": {
"object": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"member": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"security": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"shares": 10000,
"issueDate": "2026-02-21",
"status": "active"
},
"previous_attributes": {}
}
}
Envelope Fields
| Field | Type | Description |
|---|
id | string | Unique event identifier (prefixed evt_). Use for idempotency. |
type | string | Event type in {resource}.{action} format. |
api_version | string | API version that generated the event (date-based, e.g. 2026-02-21). |
created | string | ISO 8601 timestamp of when the event occurred. |
organization | string | UUID of the organization where the event occurred. |
data.object | object | The full resource object at the time of the event. |
data.previous_attributes | object | For .updated events, contains only the fields that changed with their previous values. Empty object {} for .created and .deleted events. |
Example Payloads
shareholding.created — New shares issued:
{
"id": "evt_sh_created_001",
"type": "shareholding.created",
"api_version": "2026-02-21",
"created": "2026-02-21T14:30:00Z",
"organization": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f9a",
"data": {
"object": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"member": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"security": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"shares": 50000,
"issueDate": "2026-02-21",
"pricePerShare": "1.25",
"status": "active",
"certificateNumber": "CS-001"
},
"previous_attributes": {}
}
}
member.updated — Member role changed:
{
"id": "evt_mbr_updated_002",
"type": "member.updated",
"api_version": "2026-02-21",
"created": "2026-02-21T15:45:00Z",
"organization": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f9a",
"data": {
"object": {
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"firstName": "Jane",
"lastName": "Smith",
"email": "jane@example.com",
"role": "board_member"
},
"previous_attributes": {
"role": "shareholder"
}
}
}
holding.converted — Convertible note converted to equity:
{
"id": "evt_hld_conv_003",
"type": "holding.converted",
"api_version": "2026-02-21",
"created": "2026-02-21T16:00:00Z",
"organization": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f9a",
"data": {
"object": {
"id": "f1e2d3c4-b5a6-9807-fedc-ba0987654321",
"type": "convertible_note",
"principalAmount": "250000.00",
"conversionDate": "2026-02-21",
"convertedToShareholding": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "converted"
},
"previous_attributes": {
"status": "active"
}
}
}
payment.failed — Payment failure notification:
{
"id": "evt_pay_fail_004",
"type": "payment.failed",
"api_version": "2026-02-21",
"created": "2026-02-21T09:15:00Z",
"organization": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f9a",
"data": {
"object": {
"id": "p1a2y3m4-e5n6-t789-0abc-def123456789",
"amount": "99.00",
"currency": "USD",
"failureReason": "card_declined",
"subscription": "s1u2b3s4-c5r6-i789-0abc-def123456789"
},
"previous_attributes": {}
}
}
Delivery and Retries
Transport
- Webhooks are delivered as HTTP POST requests to the registered endpoint URL
- The request body is JSON (
Content-Type: application/json)
- The registered URL must be HTTPS in production environments
- Each delivery includes the
Equa-Signature header for verification (see Signature Verification)
Timeout
Each delivery attempt has a 30-second timeout. If the endpoint does not respond with an HTTP 2xx status code within 30 seconds, the attempt is considered failed.
Retry Schedule
Failed deliveries are retried with exponential backoff:
| Attempt | Delay After Previous | Cumulative Time |
|---|
| 1 (initial) | Immediate | 0 |
| 2 | 1 minute | ~1 min |
| 3 | 5 minutes | ~6 min |
| 4 | 30 minutes | ~36 min |
| 5 | 2 hours | ~2.5 hours |
| 6 | 24 hours | ~26.5 hours |
After 6 total attempts (1 initial + 5 retries) over approximately 26 hours, the event is marked as failed. Webhook endpoints that consistently fail will be automatically disabled after 3 consecutive days of failures.
A delivery is considered successful when the endpoint returns any HTTP 2xx status
code (200, 201, 202, etc.). Any other status code — including 3xx redirects —
triggers a retry. Return 200 as quickly as possible; perform heavy processing
asynchronously.
Idempotency
Every event has a unique id field (e.g., evt_2f8a3b1c4d5e6f7890abcdef). The same event may be delivered more than once due to retries or network issues. Consumers must store processed event IDs and skip duplicates:
if (await db.hasProcessedEvent(event.id)) {
return res.status(200).json({ received: true, duplicate: true });
}
Ordering
Events are delivered in best-effort chronological order based on the created timestamp. However, due to retry scheduling and network variability, events may arrive out of order. Consumers should:
- Use the
created timestamp to determine actual event order
- Handle out-of-order delivery gracefully (e.g., ignore a
shareholding.updated that arrives before the corresponding shareholding.created)
- Fetch the current resource state via the API if ordering is critical
Signature Verification
Every webhook delivery includes an Equa-Signature header that allows the consumer to verify the payload originated from Equa and has not been tampered with.
Equa-Signature: t=1708512000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
The header contains two comma-separated key-value pairs:
| Component | Description |
|---|
t | Unix timestamp (seconds) of when the signature was generated |
v1 | HMAC-SHA256 hex digest of the signed payload |
Verification Steps
- Extract the
t (timestamp) and v1 (signature) values from the Equa-Signature header
- Construct the signed payload string:
{timestamp}.{raw_request_body} (the t value, a literal ., and the raw JSON body — do not parse and re-serialize)
- Compute the expected signature:
HMAC-SHA256(signing_secret, signed_payload) and hex-encode the result
- Compare the computed signature with the
v1 value using a constant-time comparison
- Check the timestamp is within 5 minutes of the current time to prevent replay attacks
Signing Secret
Each registered webhook endpoint receives a unique signing secret (prefixed whsec_) at creation time. The secret is displayed once and should be stored securely. If compromised, delete the webhook and create a new one.
Timestamp Tolerance
Reject any webhook where the t timestamp differs from the current server time by
more than 5 minutes (300 seconds). This prevents replay attacks where a captured
payload is re-sent at a later time.
Webhook Management Endpoints
These endpoints are proposed and not yet implemented. They describe the planned API
for managing outbound webhook registrations.
Register a Webhook
POST /v1/organization/:organization/webhook
Creates a new webhook endpoint registration for the organization.
Permission: canEditOrganization
Request Body:
| Field | Type | Required | Description |
|---|
url | string | Yes | The HTTPS URL to receive webhook deliveries |
events | string[] | No | Array of event types to subscribe to. Omit or pass ["*"] for all events. |
description | string | No | Human-readable label for this webhook |
active | boolean | No | Whether the webhook is active. Defaults to true. |
Example Request:
{
"url": "https://app.example.com/webhooks/equa",
"events": ["shareholding.created", "shareholding.transferred", "member.added"],
"description": "Cap table sync for investor portal"
}
Response (201 Created):
{
"id": "wh_a1b2c3d4e5f6",
"url": "https://app.example.com/webhooks/equa",
"events": ["shareholding.created", "shareholding.transferred", "member.added"],
"description": "Cap table sync for investor portal",
"active": true,
"secret": "whsec_live_abc123def456ghi789jkl012mno345pqr678",
"created": "2026-02-21T12:00:00Z"
}
The secret field is returned only on creation. Store it securely — it cannot be
retrieved again. If lost, delete the webhook and create a new one.
List Webhooks
GET /v1/organization/:organization/webhook
Returns all registered webhook endpoints for the organization.
Permission: canViewOrganization
Response (200 OK):
{
"data": [
{
"id": "wh_a1b2c3d4e5f6",
"url": "https://app.example.com/webhooks/equa",
"events": ["shareholding.created", "shareholding.transferred", "member.added"],
"description": "Cap table sync for investor portal",
"active": true,
"created": "2026-02-21T12:00:00Z",
"lastDelivery": {
"timestamp": "2026-02-21T14:30:00Z",
"status": "success",
"httpStatus": 200,
"eventType": "shareholding.created"
}
}
]
}
The secret field is never returned in list or get responses. It is only available
at creation time.
Delete a Webhook
DELETE /v1/organization/:organization/webhook/:webhook
Permanently removes a webhook registration. Pending retries for this webhook are cancelled.
Permission: canEditOrganization
Response (204 No Content): Empty body.
Send a Test Event
POST /v1/organization/:organization/webhook/:webhook/test
Sends a synthetic test event to the registered URL. Useful for verifying connectivity and signature verification during integration.
Permission: canEditOrganization
Request Body:
| Field | Type | Required | Description |
|---|
event_type | string | No | Event type to simulate. Defaults to organization.updated. |
Response (200 OK):
{
"success": true,
"deliveryId": "del_test_xyz789",
"httpStatus": 200,
"responseTime": 142,
"event": {
"id": "evt_test_abc123",
"type": "organization.updated"
}
}
If the test delivery fails:
{
"success": false,
"deliveryId": "del_test_xyz790",
"httpStatus": 0,
"error": "Connection timed out after 30000ms",
"event": {
"id": "evt_test_abc124",
"type": "organization.updated"
}
}
Example Integration
The following Node.js/Express example shows how to receive, verify, and process Equa webhook events.
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const WEBHOOK_SECRET = process.env.EQUA_WEBHOOK_SECRET;
const TIMESTAMP_TOLERANCE_SECONDS = 300; // 5 minutes
// Capture raw body for signature verification -- must be before json parsing
app.post('/webhooks/equa', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
// 1. Extract signature components
const signatureHeader = req.headers['equa-signature'];
if (!signatureHeader) {
return res.status(400).json({ error: 'Missing Equa-Signature header' });
}
const parts = Object.fromEntries(
signatureHeader.split(',').map(pair => {
const [key, ...rest] = pair.split('=');
return [key, rest.join('=')];
})
);
const timestamp = parseInt(parts.t, 10);
const signature = parts.v1;
if (!timestamp || !signature) {
return res.status(400).json({ error: 'Invalid Equa-Signature format' });
}
// 2. Check timestamp tolerance (replay protection)
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - timestamp) > TIMESTAMP_TOLERANCE_SECONDS) {
return res.status(400).json({ error: 'Timestamp outside tolerance window' });
}
// 3. Verify HMAC-SHA256 signature
const signedPayload = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expectedSignature, 'hex')
)) {
return res.status(400).json({ error: 'Invalid signature' });
}
// 4. Parse the verified payload
const event = JSON.parse(rawBody);
// 5. Check for duplicate delivery (idempotency)
// In production, check against a persistent store (database, Redis, etc.)
// if (await db.hasProcessedEvent(event.id)) {
// return res.status(200).json({ received: true });
// }
// 6. Process the event by type
switch (event.type) {
case 'shareholding.created':
console.log(`New shareholding issued in org ${event.organization}`);
// Sync to your investor portal, update dashboards, etc.
break;
case 'shareholding.transferred':
console.log(`Shares transferred in org ${event.organization}`);
// Update ownership records in external systems
break;
case 'member.added':
console.log(`New member added to org ${event.organization}`);
// Send welcome notification, provision access, etc.
break;
case 'member.removed':
console.log(`Member removed from org ${event.organization}`);
// Revoke external access, update directories
break;
case 'holding.converted':
console.log(`Holding converted in org ${event.organization}`);
// Update convertible note tracking
break;
case 'payment.failed':
console.log(`Payment failed for org ${event.organization}`);
// Alert finance team, send dunning notification
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
// 7. Return 200 to acknowledge receipt
// Do this promptly -- perform heavy processing asynchronously
res.status(200).json({ received: true });
});
app.listen(3000, () => {
console.log('Webhook receiver listening on port 3000');
});
Key implementation notes:
- Use
express.raw() (not express.json()) so you receive the raw bytes for signature verification. Parsing and re-serializing JSON may alter whitespace, breaking the signature.
- Always use
crypto.timingSafeEqual() for signature comparison to prevent timing attacks.
- Return
200 immediately and queue heavy processing for async workers. The 30-second delivery timeout is strict.
- Store processed event IDs in a persistent store (database, Redis) with a TTL of at least 48 hours to handle retry deduplication across the full retry window.
Best Practices
-
Use HTTPS endpoints. Webhook payloads may contain sensitive financial data (share counts, member details, payment information). Always use TLS-encrypted endpoints.
-
Respond quickly. Return a
2xx response within a few seconds. Offload processing to a background queue (e.g., Bull, SQS, Pub/Sub) to avoid timeouts.
-
Implement idempotency. Due to retries, you may receive the same event more than once. Track processed event IDs and skip duplicates.
-
Handle out-of-order events. Use the
created timestamp and the current resource state (via API calls) rather than assuming delivery order matches event order.
-
Monitor delivery health. Use the List Webhooks endpoint to check
lastDelivery status. Set up alerts for consecutive failures.
-
Rotate secrets periodically. Delete and recreate webhook registrations to rotate signing secrets. Update your verification code before deleting the old webhook.
-
Filter events at registration. Subscribe only to the event types you need. This reduces delivery volume and the surface area for processing errors.
-
Log raw payloads. Store the raw JSON body and signature header for debugging. This helps diagnose verification failures and unexpected payload formats.