Skip to main content
Webhooks let you receive real-time notifications whenever something important happens inside a project. This guide walks through creating subscriptions, understanding deliveries, verifying signatures, and the catalog of events that can be emitted by the system.
Events cover request lifecycle, auth/limits, routing outcomes, and (if you enable it) billing/ledger activity. Event keys are prefixless (for example, proxy.request.started, balance.charge.created).

1. Authentication and Base URL

All webhook management endpoints live in the OpenStack API and require the Authorization: Bearer <OPENSTACK_SECRET> header. Use the secret for the project you want to subscribe on behalf of. Requests without a valid key return 401. Unless stated otherwise, example requests assume the API base URL is https://api.openstack.ai.

2. Managing Subscriptions

2.1 Create a subscription

POST /v1/webhooks/subscriptions
Authorization: Bearer sk-openstack-v1-***
Content-Type: application/json

{
  "url": "https://example.com/openstack/events",
  "events": ["updated", "balance.*"],
  "description": "Send lifecycle + balance updates to billing service",
  "customHeaders": {
    "X-Source": "openstack"
  }
}
  • url – HTTPS endpoint that will receive deliveries.
  • events – array of event names. Use the wildcard ["*"] to receive everything. The wildcard cannot be mixed with other names.
  • description – optional, trimmed to 512 chars.
  • customHeaders – optional object of additional headers, max 10 entries.
  • secret – optional pre-shared secret. If omitted, one is generated.
Response (201)
Returns the subscription without the full secret, except on creation where the secret is included once:
{
  "subscription": {
    "id": "sub_123",
    "paywallId": "pw_456",
    "url": "https://example.com/openstack/events",
    "events": ["updated", "balance.*"],
    "isActive": true,
    "description": "Send lifecycle + balance updates to billing service",
    "customHeaders": {
      "X-Source": "openstack"
    },
    "createdAt": "2024-06-10T14:32:12.448Z",
    "updatedAt": "2024-06-10T14:32:12.448Z",
    "secret": "whsec_************************",
    "secretSuffix": "00f9a1",
    "createdBy": "user_abc",
    "consecutiveFailures": 0,
    "lastDeliveredAt": null
  }
}
⚠️ Store the secret securely. It is only returned on creation; subsequent reads show secretSuffix for diagnostics.

2.2 List subscriptions

GET /v1/webhooks/subscriptions?status=active
Authorization: Bearer sk-openstack-v1-***
  • Optional status filter (active or inactive). The default returns both.

2.3 Fetch a single subscription

GET /v1/webhooks/subscriptions/{subscriptionId}
Authorization: Bearer sk-openstack-v1-***

2.4 Deactivate a subscription

DELETE /v1/webhooks/subscriptions/{subscriptionId}
Authorization: Bearer sk-openstack-v1-***
Subscriptions are soft-deactivated (isActive: false) so you can re-enable them later via PATCH /v1/webhooks/subscriptions/{id} (not yet exposed via the REST surface).

3. Inspecting Delivery Logs

GET /v1/webhooks/logs?subscriptionId=sub_123&status=error&limit=50
Authorization: Bearer sk-openstack-v1-***
Query parameters:
ParameterDescription
subscriptionIdOptional – restrict to a single subscription.
statusOptional – pending, success, or error.
eventTypeOptional – filter by an event name defined in the catalog.
limitOptional – defaults to 20, max 100.
Each log entry includes delivery metadata (id, status, retries, timing) and the original event payload to help with debugging.

4. Delivery Payloads

When a webhook fires, we POST the following JSON document to your url:
{
  "delivery": {
    "id": "dly_abc123",
    "subscriptionId": "sub_123",
    "attempts": 1,
    "createdAt": "2024-06-10T14:32:25.133Z",
    "updatedAt": "2024-06-10T14:32:25.133Z"
  },
  "event": {
    "id": "evt_456",
    "paywallId": "pw_456",
    "ownerUserId": "user_abc",
    "type": "balance.deposit.created",
    "version": "1.0",
    "status": "pending",
    "createdAt": "2024-06-10T14:32:24.921Z",
    "trigger": {
      "source": "api",
      "actorType": "owner",
      "actorId": "user_abc"
    },
    "subject": {
      "type": "balance",
      "id": "activity_123",
      "externalId": "user_789"
    },
    "data": {
      "paywallId": "pw_456",
      "ownerUserId": "user_abc",
      "walletUserId": "user_789",
      "externalUserId": "user_789",
      "amount": "25",
      "currency": "usd",
      "source": "stripe",
      "activityId": "activity_123"
    },
    "metadata": {
      "paymentIntent": "pi_12345"
    }
  }
}

4.1 HTTP headers

Each delivery includes signature headers so you can validate authenticity:
HeaderDescription
X-Paywalls-Event<eventType>@<version> (e.g. updated@1.0).
X-Paywalls-SignatureHex-encoded HMAC-SHA256 signature of the raw request body.
X-Paywalls-TimestampISO-8601 timestamp when the payload was generated.
Verification steps
  1. Retrieve the subscription secret used for the delivery.
  2. Compute HMAC_SHA256(secret, raw_request_body) and hex-encode it.
  3. Compare to X-Paywalls-Signature using a constant-time comparison.
  4. Optionally ensure X-Paywalls-Timestamp is within an acceptable window (e.g. 5 minutes) to guard against replay attacks.

4.2 Example: Validate signatures in JavaScript

The snippet below shows a simple Node.js route that validates OpenStack signatures using the shared secret. It assumes you are using Express and have access to the raw request body.
import crypto from 'node:crypto'
import type { Request, Response } from 'express'

function verifySignature(secret: string, payload: string, signature: string): boolean {
  const computed = crypto.createHmac('sha256', secret).update(payload).digest('hex')
  // Use timingSafeEqual to avoid leaking timing information
  const expected = Buffer.from(computed, 'hex')
  const received = Buffer.from(signature, 'hex')
  if (expected.length !== received.length) {
    return false
  }
  return crypto.timingSafeEqual(expected, received)
}

export async function handleOpenStackWebhook(req: Request, res: Response) {
  const secret = process.env.PAYWALLS_WEBHOOK_SECRET
  if (!secret) {
    res.status(500).send('Webhook secret not configured')
    return
  }

  const body = req.rawBody?.toString('utf8') ?? '' // Ensure raw body middleware is configured
  const signature = req.header('X-Paywalls-Signature') ?? ''
  const timestamp = req.header('X-Paywalls-Timestamp') ?? ''

  // Optional: reject stale timestamps (example uses 5 minutes)
  const FIVE_MINUTES_MS = 5 * 60 * 1000
  const timestampMs = Date.parse(timestamp)
  if (!timestamp || isNaN(timestampMs) || Math.abs(Date.now() - timestampMs) > FIVE_MINUTES_MS) {
    res.status(400).send('Stale or missing timestamp')
    return
  }

  if (!signature || !verifySignature(secret, body, signature)) {
    res.status(400).send('Invalid signature')
  }

  let payload
  try {
    payload = JSON.parse(body)
  } catch (err) {
    res.status(400).send('Invalid JSON payload')
    return
  }
  // Process the delivery payload...
  res.status(200).send('ok')
}
}
Make sure your framework exposes the raw request body (Express requires enabling the verify option on the JSON body parser or a raw-body middleware).

5. Delivery Semantics & Retries

  • Deliveries default to 30-second base backoff with jitter and double on each retry (configurable via environment variables).
  • We retry up to WEBHOOK_MAX_ATTEMPTS (defaults to 8). Non-retryable status codes (400, 401, 403, 404, 410, 422) end the delivery immediately with an error.

6. Next Steps

  • Rotate webhook secrets periodically and update your consumer accordingly.
  • Monitor /v1/webhooks/logs for failed deliveries; retry or investigate issues surfaced via the webhook delivery logs API.
  • Use the event catalog to build targeted reactions rather than subscribing to * when possible—smaller workloads mean faster, more reliable processing.