Developer docs v0.1.0

Webhooks

Event types

EventEmitted when
customer.created · customer.updated · customer.deletedCustomer changes
instrument.attached · instrument.detachedInstrument saved or removed
enrollment.created · enrollment.updated · enrollment.failedHosted enrollment started, progressed, or failed
subscription.createdSubscription created (before the first charge settles)
subscription.updatedUpdate call, or a status change from a charge outcome (e.g. → active, past_due, unpaid, incomplete_expired)
subscription.canceledActually canceled: immediately, when the period ends after cancel_at_period_end, or after retries when the exhaustion action is canceled. Scheduling a cancellation emits subscription.updated instead.
subscription.paused · subscription.resumedPause, resume (manual, via update, or scheduled)
subscription.trial_will_endOnce, when a trial is within 3 days of ending (payload is the full subscription)
payment.review_requiredA late successful charge needs manual resolution because its subscription is expired or was already canceled
payment.review_resolvedMerchant recorded the completed external refund/service disposition
payment.succeededA charge succeeded (renewal, first payment, proration, standalone payment, or late confirmation)
payment.failedA charge was declined; includes retry_at (null for standalone payments)

Delivery

Payware POSTs one JSON envelope per event to each matching endpoint:

{
  "id": "01…",
  "object": "event",
  "type": "payment.succeeded",
  "created": "2026-10-23T12:00:00.000Z",
  "data": {
    "object": { "object": "payment", "id": "01…", "subscription_id": "01…",
                "amount": 4900, "currency": "RON", "status": "succeeded", "…": "…" }
  }
}
  • id identifies the logical event and is the same for every endpoint that receives it.
  • data.object is always the full API object: the subscription for subscription.*, the customer for customer.*, the payment transaction for payment.*, the instrument for instrument.attached. Deletions (customer.deleted, instrument.detached) carry { "object": "...", "id": "...", "deleted": true }.
  • Extra context sits next to object inside data: payment.failed adds retry_at (the next attempt, or null when retries are exhausted); instrument.detached adds replacement_instrument_id.

Headers:

HeaderValue
x-signaturet=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, keyed with the endpoint secret
x-event-typee.g. payment.succeeded
x-webhook-idID of this delivery (unique per endpoint); stable across retries

Respond with any 2xx within WEBHOOK_TIMEOUT_MS (default 10 s). Anything else — a timeout, an error, or a redirect (redirects are never followed) — is retried after 1 min, 5 min, 30 min, 2 h and 24 h; after the sixth attempt the delivery is marked failed. Delivery is at least once: dedupe on id, and expect events to arrive out of order (trust the object's status and current_period_end, not arrival order). Each retry is re-signed with a fresh timestamp.

Verifying the signature

import { createHmac, timingSafeEqual } from "node:crypto";

export const verify = (rawBody: string, header: string, secret: string, toleranceSeconds = 300) => {
  const parts = Object.fromEntries(header.split(",").map((part) => part.trim().split("=") as [string, string]));
  const timestamp = Number(parts.t);
  if (!Number.isInteger(timestamp) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false; // replay protection

  const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  return expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
};

Compute it over the raw bytes you received, before JSON parsing. The timestamp is part of the signed data, so a captured request cannot be replayed outside the tolerance window.

Secrets and endpoint URLs

  • Secrets are 256-bit random values (whsec_ + 64 hex chars), returned at creation and on matching idempotency replays; normal endpoint reads omit them.
  • POST /v1/webhooks/:id/rotate-secret issues a new secret and returns it (including on matching idempotency replays); the old one stops working immediately, so update the receiver first or accept a short window of failures (failed deliveries are retried and re-signed with the new secret).
  • URLs must use http or https and may not contain credentials. In production (NODE_ENV=production) they must also be https and may not point to localhost, *.local, *.internal, or addresses rejected by the private/reserved-address classifier. The HTTP transport validates resolved addresses in the connection lookup, preserves hostname/SNI verification, and does not perform a separate unpinned lookup after validation. Set WEBHOOK_ALLOW_PRIVATE_HOSTS=true to lift the production restriction. Violations return 400 invalid_webhook_url.