Skip to main content

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 TypeDescription
Subscription createdA new subscription is activated for an organization
Subscription cancelledAn organization’s subscription is cancelled
Payment receivedA payment is successfully processed
Payment failedA payment attempt fails
Member count updatedShareholder/member count changes trigger plan adjustments

How It Works

  1. An event occurs in Chargify (e.g., subscription state change)
  2. Chargify sends an HTTP POST to the configured webhook URL on the Equa API
  3. The billing module processes the event and updates the local database
  4. 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.
EventTrigger
shareholding.createdA new shareholding is issued (Source: captable-endpoints.tsPOST /v1/organization/:organization/shareholding)
shareholding.updatedA shareholding’s attributes are modified (Source: captable-endpoints.tsPATCH or PUT /v1/shareholding/:holding)
shareholding.voidedA shareholding is voided/cancelled (Source: captable-endpoints.tsDELETE /v1/shareholding/:holding)
shareholding.transferredShares are transferred between members (Source: captable-endpoints.tsPOST /v1/organization/:organization/shareholding/transfer)
security.createdA new security type is defined (Source: captable-endpoints.tsPOST /v1/organization/:organization/security)
security.updatedA security type’s attributes are modified (Source: captable-endpoints.tsPUT /v1/organization/:organization/security/:security)
security.deletedA security type is removed (Source: captable-endpoints.tsDELETE /v1/organization/:organization/security/:security)
option.exercisedA stock option is exercised (Source: captable-endpoints.tsPOST /v1/plan/:plan/pool/:pool/option/:option/exercise)
option.exercise_requestedAn option exercise is requested (pending approval) (Source: captable-endpoints.tsPOST /v1/plan/:plan/pool/:pool/option/:option/exercise/request)
holding.createdA new holding (e.g., option grant, convertible note) is created (Source: captable-endpoints.tsPOST /v1/holding)
holding.updatedA holding’s attributes are modified (Source: captable-endpoints.tsPUT or PATCH /v1/holding/:holding)
holding.deletedA holding is removed (Source: captable-endpoints.tsDELETE /v1/holding/:holding)
holding.convertedA convertible holding is converted to equity (Source: captable-endpoints.tsPOST /v1/holding/:holding/convert)
holding.repaidA holding is repaid (e.g., note repayment) (Source: captable-endpoints.tsPOST /v1/holding/:holding/repay)
valuation.createdA new company valuation is recorded
valuation.updatedA valuation is modified (Source: captable-endpoints.tsPUT /v1/valuation)

Organization Events

These events fire when organization-level resources change.
EventTrigger
organization.createdA new organization is created (Source: organization-endpoints.tsPOST /v1/organization)
organization.updatedAn organization’s details are modified (Source: organization-endpoints.tsPATCH /v1/organization/:organization)
member.invitedOne or more members are invited to an organization (Source: organization-endpoints.tsPOST /v1/organization/:organization/member/invite)
member.addedA new member is added to an organization (Source: organization-endpoints.tsPOST /v1/organization/:organization/member)
member.removedA member is removed from an organization (Source: organization-endpoints.tsDELETE /v1/organization/:organization/member/:member)
member.updatedA member’s details or permissions are modified (Source: organization-endpoints.tsPATCH /v1/organization/:organization/member/:member)
role.createdA new role is created (Source: organization-endpoints.tsPOST /v1/organization/:organization/role)
role.updatedA role’s permissions are modified (Source: organization-endpoints.tsPUT /v1/organization/:organization/role/:role)
role.deletedA role is removed (Source: organization-endpoints.tsDELETE /v1/organization/:organization/role/:role)

Document Events

These events fire when documents and folders are managed within an organization.
EventTrigger
document.uploadedA document is uploaded to an organization (Source: organization-endpoints.tsPOST /v1/organization/:organization/file)
document.movedA document is moved between folders
document.deletedA document is deleted
folder.createdA 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.
EventTrigger
subscription.createdA new subscription is activated (Source: billing-endpoints.tsPOST /v1/organization/:organization/subscription)
subscription.cancelledA subscription is cancelled (Source: billing-endpoints.tsDELETE /v1/organization/:organization/subscription/:subscription)
payment.succeededA payment is successfully processed
payment.failedA payment attempt fails

Event Type Summary

DomainEvent CountEvents
Cap Table16shareholding.*, security.*, option.*, holding.*, valuation.*
Organization9organization.*, member.*, role.*
Documents4document.*, folder.*
Subscriptions4subscription.*, payment.*
Total33

Payload Format

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

FieldTypeDescription
idstringUnique event identifier (prefixed evt_). Use for idempotency.
typestringEvent type in {resource}.{action} format.
api_versionstringAPI version that generated the event (date-based, e.g. 2026-02-21).
createdstringISO 8601 timestamp of when the event occurred.
organizationstringUUID of the organization where the event occurred.
data.objectobjectThe full resource object at the time of the event.
data.previous_attributesobjectFor .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:
AttemptDelay After PreviousCumulative Time
1 (initial)Immediate0
21 minute~1 min
35 minutes~6 min
430 minutes~36 min
52 hours~2.5 hours
624 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:
  1. Use the created timestamp to determine actual event order
  2. Handle out-of-order delivery gracefully (e.g., ignore a shareholding.updated that arrives before the corresponding shareholding.created)
  3. 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.

Header Format

Equa-Signature: t=1708512000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
The header contains two comma-separated key-value pairs:
ComponentDescription
tUnix timestamp (seconds) of when the signature was generated
v1HMAC-SHA256 hex digest of the signed payload

Verification Steps

  1. Extract the t (timestamp) and v1 (signature) values from the Equa-Signature header
  2. 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)
  3. Compute the expected signature: HMAC-SHA256(signing_secret, signed_payload) and hex-encode the result
  4. Compare the computed signature with the v1 value using a constant-time comparison
  5. 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:
FieldTypeRequiredDescription
urlstringYesThe HTTPS URL to receive webhook deliveries
eventsstring[]NoArray of event types to subscribe to. Omit or pass ["*"] for all events.
descriptionstringNoHuman-readable label for this webhook
activebooleanNoWhether 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:
FieldTypeRequiredDescription
event_typestringNoEvent 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

  1. Use HTTPS endpoints. Webhook payloads may contain sensitive financial data (share counts, member details, payment information). Always use TLS-encrypted endpoints.
  2. 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.
  3. Implement idempotency. Due to retries, you may receive the same event more than once. Track processed event IDs and skip duplicates.
  4. 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.
  5. Monitor delivery health. Use the List Webhooks endpoint to check lastDelivery status. Set up alerts for consecutive failures.
  6. Rotate secrets periodically. Delete and recreate webhook registrations to rotate signing secrets. Update your verification code before deleting the old webhook.
  7. Filter events at registration. Subscribe only to the event types you need. This reduces delivery volume and the surface area for processing errors.
  8. Log raw payloads. Store the raw JSON body and signature header for debugging. This helps diagnose verification failures and unexpected payload formats.