# Recurrente Webhooks

[Recurrente](https://recurrente.com) delivers webhooks through Svix, which
implements Standard Webhooks. Every delivery is an HTTP `POST` with a JSON body and three signature
headers: `svix-id`, `svix-timestamp`, and `svix-signature`. The event name is the top-level
`event_type` field. In the unified `intent.*` payload, the top-level `type` is the **payment method**
(`payment`, `bank_transfer`, `crypto`, `balance`, `cash`), not the event.

Recurrente's docs are in Spanish. Endpoints receive every event (no per-endpoint filter is
documented), so ignore the ones you don't need and return `200` for them.

## When to Use This Skill

* How do I receive Recurrente webhooks?
* How do I verify Recurrente webhook signatures (`svix-signature`)?
* How do I handle `intent.succeeded` or `intent.failed` events?
* How do I react to `subscription.past_due` or `subscription.cancel`?
* Why is my Recurrente webhook signature verification failing, or rejecting Hookdeck retries?

## Verification (core)

Signed content is `{svix-id}.{svix-timestamp}.{raw body}`. Svix signs it with HMAC-SHA256 using
the base64-decoded secret (the part after `whsec_`), base64-encodes the result, and sends one or
more space-separated `v1,<signature>` entries. Use the official `svix` library, which compares in
constant time. Read the `svix-*` headers, and when `svix-id` is absent fall back to the `webhook-*`
headers (`webhook-id`, `webhook-timestamp`, `webhook-signature`), as Hookdeck does.

```javascript
import { Webhook } from 'svix'; // svix 2.x is ESM-only

const prefix = req.headers['svix-id'] ? 'svix' : 'webhook';
const id = req.headers[`${prefix}-id`];
const timestamp = req.headers[`${prefix}-timestamp`];
const signature = req.headers[`${prefix}-signature`];
// rawBody: the raw Buffer/string, never re-serialized JSON
new Webhook(process.env.RECURRENTE_WEBHOOK_SECRET).verify(rawBody, {
  'svix-id': id, 'svix-timestamp': timestamp, 'svix-signature': signature,
  'webhook-id': id, 'webhook-timestamp': timestamp, 'webhook-signature': signature,
}); // throws on a bad signature or a timestamp outside the window
const event = JSON.parse(rawBody.toString('utf8')); // parse only after verify passes

```

```python
from svix.webhooks import Webhook, WebhookVerificationError

wh = Webhook(os.environ["RECURRENTE_WEBHOOK_SECRET"])  # "whsec_..."
wh.verify(raw_body, {"svix-id": id, "svix-timestamp": ts, "svix-signature": sig,
                     "webhook-id": id, "webhook-timestamp": ts, "webhook-signature": sig})

```

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/recurrente-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/recurrente-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/recurrente-webhooks/examples/fastapi/).

Each endpoint has its own secret, shaped like `whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw` (that is the
docs' example, not a real secret). It is returned **once**, as `signingSecret`, by
`POST /api/webhook_endpoints`, and can be read at any time in the Svix dashboard reachable from
Configuración > Desarrolladores y API > Webhooks. Keep the full `whsec_...` value in
`RECURRENTE_WEBHOOK_SECRET`. Handlers fail closed: with the variable unset they return 500 instead
of accepting anything. Missing headers or a bad signature return 400.

### Hookdeck retries and replays hit the 5-minute window

The `svix` and `standardwebhooks` libraries always enforce a fixed 5-minute timestamp window, and
it can't be configured. Hookdeck retries and replays resend the **original** `svix-timestamp`, so
the library rejects any retry or replay that happens more than 5 minutes after the original
delivery. Recurrente's docs call the timestamp check optional ("unos minutos", no number). If you
verify behind Hookdeck (or replay old events), use the manual verifier in
[references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/recurrente-webhooks/references/verification.md), where the window can be dropped or
widened. Deduplicate on `svix-id` instead of relying on the timestamp.

## Common Event Types

The name is `event_type` in the JSON body. The examples handle these:

| Event | Triggered When |
| --- | --- |
| `intent.succeeded` | A charge succeeded (any payment method); funds are in your balance |
| `intent.pending` | A charge started or is pending (bank transfer, crypto) |
| `intent.failed` | A charge failed |
| `intent.canceled` | An intent was canceled (one "l") |
| `intent.paid` | Balance payments only, sent to the paying account |
| `subscription.create` | A subscription is created (also `.past_due`, `.pause`, `.unpause`, `.reactivate`, `.update`, `.item_added`, `.item_removed`, `.invoice_item_added`, `.cancel`) |
| `setup_intent.succeeded` | A trial subscription starts, or a card is tokenized without a charge |
| `setup_intent.cancelled` | Tokenizing a card failed (two "l"s) |
| `refund.create` | A refund was processed |
| `dispute.create` | A chargeback was opened (also `dispute.update`) |
| `verification.completed` | A connected account's verification was accepted |
| `verification.rejected` | Documents were rejected; read `document_errors` |
| `tax_invoice_settings.enabled` | FEL tax invoicing finished activating |
| `account_connection.create` | A child account connected to a platform |
| `transfer.sent` | A p2p transfer was sent (also `transfer.received`) |
| `withdrawal.create` | A bank withdrawal was created (also `withdrawal.update`) |

Accounts created after the unified-format change receive only `intent.*` for payments; older
accounts also keep the legacy per-method events (`payment_intent.*`, `bank_transfer_intent.*`,
`balance_intent.*`, `crypto_intent.*`, `cash_intent.succeeded`,
`automated_bank_transfer_intent.succeeded`). See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/recurrente-webhooks/references/overview.md)
for the full list, payload shape, and retries.

## Environment Variables

```bash
# Signing secret of the webhook endpoint (Svix format, starts with whsec_)
RECURRENTE_WEBHOOK_SECRET=whsec_xxxxx

```

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 recurrente --path /webhooks/recurrente

```

Register the tunnel URL as an endpoint under Configuración > Desarrolladores y API > Webhooks, then
pay a Sandbox checkout with card `4242 4242 4242 4242` to receive `intent.succeeded`.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/recurrente-webhooks/references/overview.md) - Recurrente webhook concepts, events, payload shape, retries
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/recurrente-webhooks/references/setup.md) - Register an endpoint, get the secret, Sandbox testing
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/recurrente-webhooks/references/verification.md) - Svix verification details, header fallback, manual verifier, timestamp window