Webhooks
Event types
| Event | Emitted when |
|---|---|
customer.created · customer.updated · customer.deleted | Customer changes |
instrument.attached · instrument.detached | Instrument saved or removed |
enrollment.created · enrollment.updated · enrollment.failed | Hosted enrollment started, progressed, or failed |
subscription.created | Subscription created (before the first charge settles) |
subscription.updated | Update call, or a status change from a charge outcome (e.g. → active, past_due, unpaid, incomplete_expired) |
subscription.canceled | Actually 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.resumed | Pause, resume (manual, via update, or scheduled) |
subscription.trial_will_end | Once, when a trial is within 3 days of ending (payload is the full subscription) |
payment.review_required | A late successful charge needs manual resolution because its subscription is expired or was already canceled |
payment.review_resolved | Merchant recorded the completed external refund/service disposition |
payment.succeeded | A charge succeeded (renewal, first payment, proration, standalone payment, or late confirmation) |
payment.failed | A 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", "…": "…" }
}
}
ididentifies the logical event and is the same for every endpoint that receives it.data.objectis always the full API object: the subscription forsubscription.*, the customer forcustomer.*, the payment transaction forpayment.*, the instrument forinstrument.attached. Deletions (customer.deleted,instrument.detached) carry{ "object": "...", "id": "...", "deleted": true }.- Extra context sits next to
objectinsidedata:payment.failedaddsretry_at(the next attempt, ornullwhen retries are exhausted);instrument.detachedaddsreplacement_instrument_id.
Headers:
| Header | Value |
|---|---|
x-signature | t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, keyed with the endpoint secret |
x-event-type | e.g. payment.succeeded |
x-webhook-id | ID 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-secretissues 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
httporhttpsand may not contain credentials. In production (NODE_ENV=production) they must also behttpsand 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. SetWEBHOOK_ALLOW_PRIVATE_HOSTS=trueto lift the production restriction. Violations return400 invalid_webhook_url.