> ## Documentation Index
> Fetch the complete documentation index at: https://docs.revtain.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive real-time notifications when recovery attempts complete. Verification, events, and retry behaviour.

Revtain sends webhook notifications to the URL you registered during onboarding when a recovery attempt reaches a final state. Every webhook is signed for verification.

## Webhook Security

Every outbound webhook includes an `X-Revtain-Signature` header containing an HMAC-SHA256 signature computed using your `webhookSigningSecret`.

<Warning>
  Always verify signatures before processing webhook events. Unsigned or invalid requests should return `401 Unauthorized`.
</Warning>

## Step-by-Step Verification

<Steps>
  <Step title="Set up your webhook endpoint">
    HTTPS endpoint that accepts POST with JSON body and returns `200 OK`.
  </Step>

  <Step title="Store your webhook signing secret">
    Save `webhookSigningSecret` securely. This is **different** from your API key.
  </Step>

  <Step title="Verify every incoming request">
    Compute HMAC-SHA256 using your secret, compare with `X-Revtain-Signature`.
  </Step>

  <Step title="Process the event and respond 200">
    Always respond within 10 seconds. Slow endpoints trigger retries.
  </Step>
</Steps>

## Code Examples

<CodeGroup>
  <Warning>
    Sign against the **raw request body**, exactly as received — not a re-serialized copy. The signature is computed over the precise bytes Revtain sent. If you let your framework parse the JSON and then re-stringify it (`JSON.stringify(req.body)`), key ordering or whitespace can differ and every signature check will fail. Capture the raw body first, verify, then parse.
  </Warning>

  ```javascript Node.js theme={null}
  const crypto = require('crypto');
  const express = require('express');
  const app = express();

  // Capture the RAW body for this route — do not use express.json() here.
  app.post('/webhooks/revtain', express.raw({ type: 'application/json' }), (req, res) => {
    const signature = req.headers['x-revtain-signature'];
    const webhookSecret = process.env.REVTAIN_WEBHOOK_SECRET;

    // req.body is a Buffer (the raw bytes Revtain sent). Hash it directly.
    const expected = crypto
      .createHmac('sha256', webhookSecret)
      .update(req.body)
      .digest('hex');

    // Constant-time comparison — never use !== on a signature.
    const ok =
      signature &&
      signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

    if (!ok) {
      console.error('Invalid webhook signature — rejecting');
      return res.status(401).send('Invalid signature');
    }

    // Signature valid — now it is safe to parse.
    const payload = JSON.parse(req.body.toString('utf8'));
    console.log(`Received verified webhook: ${payload.event}`);

    res.status(200).send('OK');
  });
  ```

  ```python Python theme={null}
  import hmac, hashlib, os, json
  from flask import Flask, request, abort

  app = Flask(__name__)

  @app.route('/webhooks/revtain', methods=['POST'])
  def handle_webhook():
      signature = request.headers.get('X-Revtain-Signature', '')
      webhook_secret = os.environ['REVTAIN_WEBHOOK_SECRET']

      # Hash the RAW request body — the exact bytes Revtain sent.
      raw_body = request.get_data()  # bytes, before any JSON parsing
      expected = hmac.new(webhook_secret.encode(), raw_body, hashlib.sha256).hexdigest()

      # Constant-time comparison.
      if not hmac.compare_digest(signature, expected):
          abort(401)

      payload = json.loads(raw_body)
      print(f"Received verified webhook: {payload.get('event')}")

      return 'OK', 200
  ```

  ```bash cURL (Test) theme={null}
  # Reproduce a signature locally. The body passed to openssl must be byte-identical
  # to what your server receives, so sign and send the SAME string.
  SECRET="whsec_YOUR_WEBHOOK_SECRET"
  PAYLOAD='{"event":"recovery.success","revtainTransactionId":"test_123","amount":5000}'
  SIGNATURE=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

  curl -X POST https://your-server.com/webhooks/revtain \
    -H "Content-Type: application/json" \
    -H "X-Revtain-Signature: $SIGNATURE" \
    -d "$PAYLOAD"
  ```
</CodeGroup>

<Note>
  **Always respond with `200` within 10 seconds.** If your endpoint is down or slow, Revtain retries delivery automatically — three immediate attempts (0s, 2s, 8s), then, if those fail, a durable retry schedule over the following 24 hours. A brief outage on your side will not lose an event.
</Note>

## Event Reference

### `recovery.success`

Sent when a previously failed payment has been successfully recovered.

```json theme={null}
{
  "event": "recovery.success",
  "revtainTransactionId": "da646dba-ce56-4483-ad0a-2a4fac54a5e2",
  "gatewayTransactionToken": "pi_3OZQ8K2eZvKYlo2C1abc1234",
  "amount": 5000,
  "currency": "USD",
  "message": "Succeeded!",
  "strategyUsed": "primary"
}
```

**Action:** Mark the customer's invoice as paid in your billing system.

<Note>
  **Correlating a webhook back to your invoice.** When you call `/api/recovery/execute`, the response includes a `transactionId`. Store it against your invoice. The webhook's `revtainTransactionId` is the **same value** — match on it to know which invoice this outcome belongs to. If you prefer to drive everything from your own key, pass an `idempotencyKey` (your invoice ID) on `/execute` and poll [`GET /api/recovery/status?idempotencyKey=...`](/api-reference) instead of waiting on the webhook.
</Note>

<Note>
  `recovery.success` and `recovery.failed` may carry an optional `trigger` field identifying what produced the outcome — for example a scheduled retry, or `"reconciliation"` when Revtain's automatic reconciliation resolved a payment whose original gateway response was lost in transit. Handle both events the same way regardless of `trigger`; the field exists for your telemetry.
</Note>

***

### `recovery.failed`

Sent when all retry strategies have been exhausted.

```json theme={null}
{
  "event": "recovery.failed",
  "revtainTransactionId": "da646dba-ce56-4483-ad0a-2a4fac54a5e2",
  "gatewayTransactionToken": "FAILED_API_1773792234011",
  "amount": 5000,
  "currency": "USD",
  "message": "Card declined",
  "strategyUsed": "all_failed",
  "recommendedAction": "retry_later",
  "recommendedActionReason": "The issuer returned a soft decline. A later retry, or a card update, may succeed."
}
```

**Action:** Branch on `recommendedAction` (see table below) and run your own customer outreach from your domain — see the [dunning guide](/guides/dunning). Revtain never emails or messages your customers directly.

#### `recommendedAction` values

`recovery.failed` and `recovery.blocked` both carry a `recommendedAction` field, so your dunning logic can branch on a stable signal instead of parsing raw decline codes. `recommendedActionReason` is a human-readable explanation of the same signal.

| Value                 | What to do                                                                                                                     |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `retry_later`         | A soft or transient decline. A later retry — or a card update — may succeed.                                                   |
| `request_card_update` | The card is expired, invalid, or needs customer authentication. Ask the customer to update or re-confirm their payment method. |
| `manual_review`       | The issuer flagged the card for fraud. Do not retry — review the account.                                                      |
| `monitor`             | The reason is ambiguous (for example a suspected duplicate). Verify before retrying.                                           |

***

### `recovery.blocked`

Sent when a transaction is stopped before any retry — by the Risk Engine (fraud, lost card), because the issuer requires the customer to authenticate, or because the card was reported no longer chargeable (removed or replaced at the gateway; `declineCode: card_no_longer_chargeable`). In every case `recommendedAction` tells you the right next move.

```json theme={null}
{
  "event": "recovery.blocked",
  "declineCode": "fraudulent",
  "reason": "Transaction blocked by Revtain Risk Engine to protect Merchant Health.",
  "recommendedAction": "manual_review",
  "recommendedActionReason": "The issuer flagged this card for fraud. Do not retry — review the account.",
  "paymentMethodToken": "pm_1234567890",
  "amount": 5000,
  "currency": "USD",
  "timestamp": "2026-03-17T23:15:21.000Z"
}
```

**Action:** Branch on `recommendedAction`. A `manual_review` value means flag the account and do not retry. A `request_card_update` value — for example when `declineCode` is `authentication_required` — means route the customer through your checkout to re-confirm their card.

***

### `card.updated`

Sent when a customer updates their payment method via a hosted card update link.

```json theme={null}
{
  "event": "card.updated",
  "oldPaymentMethodToken": "pm_oldcard123",
  "newPaymentMethodToken": "pm_newcard456",
  "paymentMethodType": "apple_pay",
  "eligibleGateways": ["stripe", "adyen"],
  "clientId": "da646dba-ce56-4483-ad0a-2a4fac54a5e2",
  "timestamp": "2026-04-20T14:30:00.000Z"
}
```

| Field                   | Type      | Description                                                                                                                                                                                                                     |
| ----------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `oldPaymentMethodToken` | string    | The token that was dead and triggered the update flow. Stop using it.                                                                                                                                                           |
| `newPaymentMethodToken` | string    | The new gateway-native token. Use this on all future recovery calls.                                                                                                                                                            |
| `paymentMethodType`     | string    | `card`, `apple_pay`, `google_pay`, `paypal`, or `venmo` — what the customer chose on the update page. Pass this back as `paymentMethodType` on future `/api/recovery/execute` calls so the engine applies the right strategy.   |
| `eligibleGateways`      | string\[] | The list of your configured gateways the new token can be charged on. For card tokens this is usually every gateway; for wallet tokens it's gateway-locked (the wallet sheet binds the token to whichever gateway received it). |
| `clientId`              | string    | Your Revtain client ID — same value Revtain assigned at onboarding.                                                                                                                                                             |
| `timestamp`             | string    | ISO 8601 timestamp of the update event.                                                                                                                                                                                         |

**Action:** Store `newPaymentMethodToken` and `paymentMethodType` against the customer record in your system. Use them on all future `POST /api/recovery/execute` calls for this customer. The old token is no longer valid for recovery — discard it.

<Note>
  `newPaymentMethodToken` is a gateway-native token (for example a Stripe `pm_xxx` or a Checkout.com `src_xxx`), issued by the same gateway that holds the customer's account. You can pass it to your gateway directly as well — it's a real gateway token, not a Revtain reference.
</Note>

***

### `predict.risk.high`

Sent when the pre-failure risk prediction engine detects a high-risk payment **before** it fails. Enables proactive intervention.

```json theme={null}
{
  "event": "predict.risk.high",
  "paymentMethodToken": "pm_1234567890",
  "riskScore": 82.5,
  "reasoning": "Card has 3 prior declines in last 30 days. High-risk pattern detected.",
  "amount": 5000,
  "timestamp": "2026-04-20T14:30:00.000Z"
}
```

**Action:** Consider reaching out to the customer to update their payment method before the next billing cycle. Generate a card update link via [`/api/recovery/update-card/generate`](/api-reference).

***

### `recovery.skipped_high_risk`

Sent when the predictor scored the failure so low that attempting recovery would burn issuer velocity budget without a realistic chance of success. Revtain skips the retry entirely and surfaces a pre-generated card update link so you can route the customer straight to re-entering payment details.

```json theme={null}
{
  "event": "recovery.skipped_high_risk",
  "paymentMethodToken": "pm_1234567890",
  "amount": 5000,
  "currency": "USD",
  "riskScore": 92,
  "confidence": 88,
  "reasoning": "Card has 5 prior hard declines in the last 7 days and the predicted retry success probability is below the configured threshold.",
  "cardUpdateUrl": "https://pay.yourdomain.com/api/recovery/update-card/abc123xyz",
  "skippedChargeId": "skip_a1b2c3d4",
  "timestamp": "2026-04-20T14:30:00.000Z"
}
```

| Field             | Type   | Description                                                                                                     |
| ----------------- | ------ | --------------------------------------------------------------------------------------------------------------- |
| `riskScore`       | number | 0–100, where higher = more likely to fail. The configured skip threshold lives on your client record.           |
| `confidence`      | number | 0–100 confidence in the prediction. Useful for filtering — a high score with low confidence is a weaker signal. |
| `reasoning`       | string | Human-readable explanation of why this card was skipped.                                                        |
| `cardUpdateUrl`   | string | A ready-to-send card update link the customer can use to provide new credentials.                               |
| `skippedChargeId` | string | Internal ID for the skip event — quote this if you need to ask support about a specific decision.               |

**Action:** Send the `cardUpdateUrl` to the customer via your existing channel (email, in-app message). Treating a skip as "the recovery failed" lets you maintain a clean state machine in your dunning logic.

<Tip>
  This event is additive — older integrations that don't handle it can safely ignore it. The same skip also fires Revtain's normal dunning hooks, so you don't need to wire `recovery.skipped_high_risk` unless you want richer telemetry on why a particular charge was skipped.
</Tip>

***

### `recovery.holdout`

Sent only on accounts running [Proof Mode](/concepts/proof-mode). The request landed in the measurement control group: Revtain made no recovery attempt and will not make one.

```json theme={null}
{
  "event": "recovery.holdout",
  "clientId": "da646dba-ce56-4483-ad0a-2a4fac54a5e2",
  "paymentMethodToken": "pm_1234567890",
  "amount": 4900,
  "currency": "USD",
  "customerEmail": "customer@example.com",
  "message": "Proof mode control group: Revtain made no recovery attempt for this payment. Proceed with your standard failed-payment process.",
  "timestamp": "2026-06-13T10:15:00.000Z"
}
```

**Action:** run your standard failed-payment process for this payment — exactly what you did before Revtain. If your integration doesn't handle this event, nothing breaks: the synchronous API response for held-out requests reports `success: false`, so your existing failure path already does the right thing.

***

### `recovery.proactive_retention`

Sent when Revtain detects that a single customer has hit multiple recovery failures in a short window — a strong signal they're at risk of churning. The event carries a pre-generated cancel-flow URL you can offer the customer to retain them with a pause, discount, or downgrade option *before* they actively cancel.

```json theme={null}
{
  "event": "recovery.proactive_retention",
  "customerEmail": "customer@example.com",
  "recentFailureCount": 3,
  "cancelFlowUrl": "https://pay.yourdomain.com/api/cancel-flow/abc123xyz",
  "cancelFlowToken": "abc123xyz",
  "amount": 2900,
  "currency": "USD",
  "reason": "Customer has had repeated recovery failures. Send this link via your existing channel to offer retention options before they churn.",
  "timestamp": "2026-04-20T14:30:00.000Z"
}
```

| Field                | Type   | Description                                                                                                                  |
| -------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `customerEmail`      | string | The customer whose failure pattern triggered the proactive retention flag.                                                   |
| `recentFailureCount` | number | How many failed recoveries this customer has had in the recent window.                                                       |
| `cancelFlowUrl`      | string | A hosted cancel-flow URL the customer can use to pause, downgrade, or cancel — your retention surface for at-risk customers. |
| `cancelFlowToken`    | string | The raw token (same one embedded in `cancelFlowUrl`) so you can build your own page if you'd rather host it yourself.        |
| `reason`             | string | Plain-language explanation of why this was triggered — safe to surface in internal dashboards.                               |

**Action:** Send `cancelFlowUrl` to the customer via your existing channel. Customers who reach this link voluntarily are dramatically more likely to retain (via pause / downgrade) than customers who are forced through the dunning gauntlet first. Requires the cancel-flow feature to be enabled on your account.

<Note>
  `recovery.proactive_retention` is only fired for clients with the cancel-flow feature enabled. If you don't use cancel flow today, this event never fires — you can safely ignore it.
</Note>

***

### `card.expiring_soon`

Sent ahead of a renewal when the customer's card on file is close to its expiry date, so you can prompt the customer before the payment fails. Fired by the pre-renewal card health check.

```json theme={null}
{
  "event": "card.expiring_soon",
  "clientId": "da646dba-ce56-4483-ad0a-2a4fac54a5e2",
  "paymentMethodToken": "pm_1234567890",
  "expiryMonth": 8,
  "expiryYear": 2026,
  "message": "Card expires 8/2026 — consider prompting customer to update."
}
```

**Action:** Reach out to the customer and ask them to update their card before the next renewal.

<Note>
  Field names vary by source: events from Revtain's own sweeps use `expiryMonth`/`expiryYear`, while events triggered by your billing platform's upcoming-invoice notification use `expMonth`/`expYear` and may include `daysUntilExpiry` and `upcomingInvoiceAmount`. Handle both spellings, and treat any additional fields as optional and forward-compatible.
</Note>

#### Pre-emptive variant (card health sweep)

Accounts with the card health sweep enabled also receive `card.expiring_soon` from Revtain's own monitoring — a routine background check of active cards, independent of any billing platform. This variant is marked `preemptive: true` and includes a ready-made card-update link:

```json theme={null}
{
  "event": "card.expiring_soon",
  "clientId": "da646dba-ce56-4483-ad0a-2a4fac54a5e2",
  "paymentMethodToken": "pm_1234567890",
  "customerEmail": "customer@example.com",
  "expiryMonth": 7,
  "expiryYear": 2026,
  "cardUpdateUrl": "https://pay.yourdomain.com/update-card/abc123xyz",
  "preemptive": true,
  "message": "Card expires 7/2026. Send the customer the card update link before the next billing attempt.",
  "timestamp": "2026-06-13T04:00:00.000Z"
}
```

**Action:** forward `cardUpdateUrl` to the customer through your own channel. The link stays valid for 14 days. Updating the card before the renewal means the decline never happens — the cheapest recovery there is.

***

### `recovery.3ds_recommended`

Sent after a failed recovery when the decline pattern suggests the charge would clear with 3-D Secure authentication — typically a soft decline from a European or UK issuer.

```json theme={null}
{
  "event": "recovery.3ds_recommended",
  "paymentMethodToken": "pm_1234567890",
  "amount": 5000,
  "currency": "USD",
  "declineCode": "do_not_honor",
  "reason": "Soft decline from EU/UK issuer may benefit from 3D Secure authentication. Re-attempt this charge with 3DS enabled on your checkout."
}
```

**Action:** Re-attempt the charge through your own checkout with 3-D Secure enabled, so the customer can authenticate.

***

### `recovery.card_expired`

Sent when Revtain confirms a stored card is expired and cannot be recovered automatically — either at retry time, or after the card failed to refresh through the network's automatic updater window. This is your cue to collect a new card.

```json theme={null}
{
  "event": "recovery.card_expired",
  "clientId": "da646dba-ce56-4483-ad0a-2a4fac54a5e2",
  "paymentMethodToken": "pm_1234567890",
  "amount": 5000,
  "currency": "USD",
  "message": "Card expired — customer must update payment method."
}
```

**Action:** Generate a card update link (or use the one from a related event) and send it to the customer through your own channel. Delivery of this event is durable — a brief outage on your side will not lose it.

***

### `recovery.authentication_required`

Sent when a recovery attempt was declined because the issuer insists the customer authenticates (3-D Secure / SCA), and the compliant merchant-initiated path could not clear it. Only the customer can resolve this one.

```json theme={null}
{
  "event": "recovery.authentication_required",
  "paymentMethodToken": "pm_1234567890",
  "amount": 5000,
  "currency": "USD",
  "declineCode": "authentication_required",
  "customerAuthRequired": true,
  "reason": "The issuer requires the cardholder to authenticate. Route the customer through your checkout to complete authentication."
}
```

**Action:** Bring the customer back through your own checkout with authentication enabled, then re-attempt the charge from your side.

***

### `churn.flow.{outcome}`

Sent when a customer completes the hosted [cancel flow](/api-reference). The event name carries the outcome: `churn.flow.retained`, `churn.flow.paused`, `churn.flow.downgraded`, or `churn.flow.cancelled`.

```json theme={null}
{
  "event": "churn.flow.paused",
  "customerId": "cus_abc123",
  "customerEmail": "customer@example.com",
  "subscriptionId": "sub_789",
  "outcome": "paused",
  "timestamp": "2026-05-21T16:40:00.000Z"
}
```

**Action:** Apply the customer's decision in your billing system — pause, downgrade, or cancel the subscription, or take no action if they were retained.
