> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.recurrente.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.recurrente.com/_mcp/server.

# Webhooks

> Receive real-time notifications about events

Webhooks notify your application in real-time when events happen — payments, subscription changes, refunds, and more. Instead of polling for a payment's status, you register a URL and Recurrente makes a `POST` to it whenever something happens.

See the [complete event catalog](/referencia-api/api-reference/webhook-events/intent-succeeded-webhook) for when each webhook fires, its schema, and an example payload.

Integrating takes four steps: register your endpoint, handle the event, verify the signature, and test it.

### Connected accounts: verification and FEL

* `verification.completed`: the account verification was accepted.
* `verification.rejected`: one or more documents were rejected; inspect `document_errors`.
* `tax_invoice_settings.enabled`: FEL activation finished and invoicing is ready. `tax_invoicing` contains the public state, without provider credentials.

The platform receives these events with `connected: true` and the child account's `account_id`. Acceptance and activation payloads preserve the state at the transition; query `GET /connected_accounts/{id}/tax_invoicing` for the current FEL state. Saving data without changing status does not emit another event. See the [event catalog](/referencia-api/api-reference/webhook-events) for schemas and examples.

### New accounts and compatibility

Accounts created after this change is enabled receive only `intent.*` when a unified replacement exists: card, bank transfer, crypto, balance, and cash. Existing accounts keep both formats, even when registering a new endpoint. Events without a replacement, such as `automated_bank_transfer_intent.*`, `setup_intent.*`, `subscription.*`, and `refund.*`, are unchanged; creation or update events without a unified equivalent are also preserved.

The rule follows the account receiving the webhook: an existing platform keeps both formats for its new connected accounts. Each new Sandbox uses the unified format even if its LIVE account predates the change.

The unified payload includes `legacy_id`, the same payment’s ID in the legacy per-type event. Use it to match historical records during migration; the canonical ID remains `id`.

## 1. Register your endpoint

An endpoint is a public URL on your server where you want to receive notifications (e.g. `https://yoursite.com/webhooks/recurrente`). Private, local, and metadata addresses are rejected.

### Via Dashboard

Within your Recurrente account, go to **Settings** → **Developers and API**, click **"Webhooks"**, and add the URL where you want the requests to be sent.

### Via API

```bash
curl -X POST https://app.recurrente.com/api/webhook_endpoints \
  -H "X-SECRET-KEY: ..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yoursite.com/webhooks/recurrente",
    "description": "Production webhook"
  }'
```

You can register several URLs (production, staging, logging); each event is sent to all of them.

## 2. Handle the event

Each webhook delivers a JSON event with this structure. Your handler branches on the `type` field to know which payment method generated it, and on `status` to know what happened.

The payload has three layers:

**Envelope** — the event's identifying fields:

* **`type`** — the payment type: `payment`, `bank_transfer`, `crypto`, `balance`, `cash`.
* **`event_type`** — the event: `intent.succeeded`, `intent.failed`, `intent.pending`, `intent.canceled`, `intent.paid`.
* **`status`** — the state: `pending`, `succeeded`, `failed`, `canceled`, `paid`. Some intermediate states (e.g. card) are exposed as `pending`; the processor's exact value is always in `raw_status`.
* **`raw_status`**, `id`, `legacy_id`, `receipt_number`, `api_version`, `created_at`.

**Payment data** — `amount_in_cents`, `currency`, `customer`, `product`, `tax_invoice_url`, `checkout`, `payment`.

**`details`** — fields specific to the payment method. For `payment`: fees, installments, channel, etc. For `bank_transfer`: `bank_reference` (the reference assigned by the sender's bank or the payment network) and `sender_comment` (the free-text comment the payer wrote on the transfer).

*Example (`intent.succeeded`, card payment):*

```json
{
  "type": "payment",
  "event_type": "intent.succeeded",
  "api_version": "2026-06-01",
  "id": "in_id123",
  "legacy_id": "pa_id123",
  "receipt_number": 100482,
  "status": "succeeded",
  "raw_status": "succeeded",
  "created_at": "2026-06-01T14:03:21Z",
  "customer_id": "cus_id123",
  "user_id": "usr_id123",
  "customer": {
    "id": "cus_id123",
    "email": "customer@example.com",
    "full_name": "Ana Lopez",
    "nit": "CF",
    "phone": "+50255551234"
  },
  "amount_in_cents": 25000,
  "currency": "GTQ",
  "product": { "id": "prod_id123" },
  "tax_invoice_url": null,
  "checkout": { "id": "ch_id123", "status": "paid" },
  "payment": { "id": "pa_id123" },
  "details": {
    "failure_reason": null,
    "fee": 875,
    "tax_invoicing_fee": 0,
    "vat_withheld": 0,
    "vat_withheld_currency": "GTQ",
    "used_presaved_payment_method": false,
    "installments": 1,
    "channel": "Tarjeta",
    "products": [{ "id": "prod_id123", "quantity": 1 }]
  }
}
```

The envelope and payment data are the same for all types; only `type` and the `details` contents change. So a single handler covers every payment method:

```js
app.post("/webhooks/recurrente", (req, res) => {
  const intent = req.body

  switch (intent.type) {
    case "payment":       /* intent.details.fee, .installments, ... */ break
    case "bank_transfer": /* intent.details.bank_reference, .sender_comment */ break
    case "crypto":
    case "balance":
    case "cash":          break
  }

  // state: always intent.status ∈ { pending, succeeded, failed, canceled, paid }
  res.sendStatus(200)
})
```

> **Note**
>
> You can also fetch any payment via API with `GET /api/intents/{id}` — it returns exactly the same format as the webhook.

## 3. Verify the signature

Every webhook includes a signature so you can verify it came from Recurrente and wasn't tampered with. We strongly recommend verifying signatures in production.

### Signing Secret

Each endpoint has a unique signing secret, formatted as `whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw`. There are two ways to get it:

* **Via API:** the `signingSecret` field is returned in the response when you create the endpoint (`POST /api/webhook_endpoints`). It is only returned in that creation response, so store it securely.
* **Via dashboard:** you can find it at any time in the Svix dashboard — accessible from **Settings → Developers and API → Webhooks** in your Recurrente account.

### Verifying with Official Libraries (Recommended)

The easiest way to verify signatures is using the [Svix webhook verification libraries](https://docs.svix.com/receiving/verifying-payloads/how), available for many languages:

**`Node.js`**

```javascript title="Node.js"
// npm install svix@2.5.0
const { Webhook } = require("svix");

const wh = new Webhook(secret);
// Verify the original body before parsing JSON.
wh.verify(rawBody, headers);
const payload = JSON.parse(rawBody);
```

**`Python`**

```python title="Python"
# pip install svix
from svix.webhooks import Webhook

wh = Webhook(secret)
# Throws on error, returns the verified content on success
payload = wh.verify(raw_body, headers)
```

**`Ruby`**

```ruby title="Ruby"
# gem install svix
require "svix"

wh = Svix::Webhook.new(secret)
# Raises on error, returns the verified content on success
payload = wh.verify(raw_body, headers)
```

**`PHP`**

```php title="PHP"
// composer require svix/svix
$wh = new \Svix\Webhook($secret);
// Verify the original body before parsing JSON.
$payload = $wh->verify($raw_body, $headers);
```

> **Warning**
>
> You must use the **raw request body** when verifying webhooks. Parsing the JSON and re-serializing it will break the signature verification.

### Verifying Manually

Each webhook request includes three headers:

| Header           | Description                                           |
| ---------------- | ----------------------------------------------------- |
| `svix-id`        | Unique message identifier (stays the same on retries) |
| `svix-timestamp` | Unix timestamp (seconds) when the webhook was sent    |
| `svix-signature` | Base64-encoded signature(s), prefixed with `v1,`      |

**Step 1.** Construct the signed content by concatenating the `svix-id`, `svix-timestamp`, and the raw request body, separated by dots:

```
signed_content = "${svix_id}.${svix_timestamp}.${body}"
```

**Step 2.** Decode the base64 portion of your signing secret (everything after `whsec_`) and use it as the key for an HMAC-SHA256 hash of the signed content:

```javascript
const crypto = require("crypto");

const secretBytes = Buffer.from(secret.split("_")[1], "base64");
const signature = crypto
  .createHmac("sha256", secretBytes)
  .update(signedContent)
  .digest("base64");
```

**Step 3.** Compare your computed signature with the one in the `svix-signature` header. The header value is prefixed with `v1,` — strip that prefix before comparing. Use a **constant-time comparison** to prevent timing attacks.

**Step 4.** Optionally, verify that the `svix-timestamp` is within a few minutes of your server's current time to prevent replay attacks.

## 4. Test it

A Sandbox delivers webhooks so you can build your integration without moving real money. Paying a Sandbox checkout with the `4242 4242 4242 4242` card fires `intent.succeeded` (`type: payment`). Sandboxes predating the change also keep `payment_intent.succeeded`. Events are delivered only to endpoints registered with that Sandbox's **TEST** key (`sk_test_*`). Test payments only simulate cards.

You can also open the webhook dashboard and use the **Testing** tab to send your endpoint an example of any event in the catalog. The resulting message shows the payload, delivery attempts, and whether delivery succeeded. Test payments only simulate cards; use the corresponding environment to exercise the real processing flow for other payment methods.

## Event types

### Payment events (`intent.*`)

| Event              | When it's emitted                                                                                                                    |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `intent.succeeded` | Successful charge (any payment method). The funds are already in your balance.                                                       |
| `intent.pending`   | Charge initiated or pending (bank transfer, crypto).                                                                                 |
| `intent.failed`    | Failed charge.                                                                                                                       |
| `intent.canceled`  | Intent canceled.                                                                                                                     |
| `intent.paid`      | Balance payments only: delivered to the **payer** account (the account whose balance was debited), if it has an endpoint configured. |

> **Note**
>
> A single balance charge triggers two independent webhooks: `intent.succeeded` (`type: balance`) to the merchant account and `intent.paid` to the payer account.

### Subscription events

| Event                                                  | When it's emitted                                                                                                                            |
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `subscription.create`                                  | A subscription is created. If the product is recurring, this is emitted in addition to `intent.succeeded` with the subscription information. |
| `subscription.past_due`                                | A subscription's automatic charge fails for the first time.                                                                                  |
| `subscription.pause`                                   | A subscription is paused. A paused subscription is not charged again until it is reactivated.                                                |
| `subscription.unpause`                                 | A paused subscription is resumed.                                                                                                            |
| `subscription.reactivate`                              | A canceled subscription is reactivated within the allowed period.                                                                            |
| `subscription.update`                                  | General information changes, such as the payment method.                                                                                     |
| `subscription.item_added`, `subscription.item_removed` | A recurring product is added or removed.                                                                                                     |
| `subscription.invoice_item_added`                      | A one-time charge is added to the next invoice.                                                                                              |
| `subscription.cancel`                                  | The automatic charge fails for the third time.                                                                                               |

*Note: in a subscription, when a payment fails, Recurrente attempts to charge it again 3 and 5 days later. If both retry attempts fail, the subscription is canceled at that time.*

### Other events

| Event                                                                                                                                                    | When it's emitted                                                                                                    |
| -------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `setup_intent.succeeded`                                                                                                                                 | A subscription with a trial period is successfully initiated, or a card is tokenized without charging it.            |
| `setup_intent.cancelled`                                                                                                                                 | A card cannot be tokenized without charging it (e.g. the first payment of a subscription with a trial period fails). |
| `payment_intent.*`, `bank_transfer_intent.*`, `automated_bank_transfer_intent.succeeded`, `balance_intent.*`, `crypto_intent.*`, `cash_intent.succeeded` | Legacy per-payment-method events.                                                                                    |
| `refund.create`                                                                                                                                          | Refund processed.                                                                                                    |
| `dispute.create`, `dispute.update`                                                                                                                       | Chargeback events. See [Dispute lifecycle](/guides-english/guides/dispute-lifecycle).                                |
| `account_connection.create`                                                                                                                              | A child account connects to a platform.                                                                              |
| `partner_commission_invoice.issued`, `partner_commission_invoice.failed`                                                                                 | The daily commission tax invoice was issued or reached a terminal failure.                                           |

### Money-movement events

| Event                                                          | When it's emitted                                               |
| -------------------------------------------------------------- | --------------------------------------------------------------- |
| `transfer.sent`, `transfer.received`                           | P2p transfer sent / received.                                   |
| `transfer.pending`, `transfer.in_review`, `transfer.cancelled` | P2P awaiting evaluation / human review / rejected after review. |
| `withdrawal.create`, `withdrawal.update`                       | Bank payout created / status change.                            |
| `swap.create`, `swap.update`                                   | Stablecoin send created / status change.                        |

`withdrawal.*` and `swap.*` events include a `transfer` key carrying the same unified shape `GET /api/transfers/{id}` returns (`transfer.*` events already use it as their main shape). If you integrate against the unified endpoint, always read that key — see the [Move money](/guides-english/guides/move-money) guide.

For payouts, read `payload.withdrawal.status` (or `payload.transfer.status` in the unified shape). A `withdrawal.update` with `status: "sent"` is terminal success for local bank payouts: run your next step and do not wait for a later `completed`. If `completed` arrives, treat it only as an additional confirmation.

## Retries

If your endpoint doesn't respond with a 2xx code, Recurrente will retry sending the webhook:

* Immediately after the first failure
* 1 minute later
* 5 minutes later
* 30 minutes later
* 2 hours later
* 6 hours later

## Best Practices

* **Return 2xx quickly** — Acknowledge receipt before doing heavy processing
* **Handle duplicates** — Webhooks may be delivered more than once; use the event ID for idempotency
* **Verify the source** — Validate webhook signatures as described above
* **Process asynchronously** — Use a queue for heavy processing after acknowledging receipt

---

*Does your account receive per-type events (`payment_intent.*`, `bank_transfer_intent.*`, etc.)? They keep working unchanged — see [Legacy events](/webhooks-legacy-en) or the guide to [migrate to the unified format](/guides-english/guides/migrate-to-unified-webhooks).*

P2P transfers can remain `in_review`. The amount is reserved until approval. Rejection changes the status to `cancelled` and returns the amount to the sender.